diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index b2aa76450..4fc7a5b73 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -9,9 +9,12 @@ ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
-# Install additional OS packages
+# [Optional] Uncomment this section to install additional OS packages.
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
- && apt-get -y install --no-install-recommends ffmpeg
+ && apt-get -y install --no-install-recommends libtag1-dev ffmpeg
+
+# [Optional] Uncomment the next line to use go get to install anything else you need
+# RUN go get -x
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index efe965428..ff58994db 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.25",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24"
@@ -54,10 +54,12 @@
4533,
4633
],
+ // Use 'postCreateCommand' to run commands after the container is created.
+ // "postCreateCommand": "make setup-dev",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"remoteEnv": {
"ND_MUSICFOLDER": "./music",
"ND_DATAFOLDER": "./data"
}
-}
\ No newline at end of file
+}
diff --git a/.dockerignore b/.dockerignore
index eb012c6e2..596aa2955 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -15,5 +15,4 @@ dist
binaries
cache
music
-music.old
!Dockerfile
\ No newline at end of file
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/download-taglib/action.yml b/.github/actions/download-taglib/action.yml
new file mode 100644
index 000000000..ea6de8783
--- /dev/null
+++ b/.github/actions/download-taglib/action.yml
@@ -0,0 +1,23 @@
+name: 'Download TagLib'
+description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
+inputs:
+ version:
+ description: 'Version of TagLib to download'
+ required: true
+ platform:
+ description: 'Platform to download TagLib for'
+ default: 'linux-amd64'
+runs:
+ using: 'composite'
+ steps:
+ - name: Download TagLib
+ shell: bash
+ run: |
+ mkdir -p /tmp/taglib
+ cd /tmp
+ FILE=taglib-${{ inputs.platform }}.tar.gz
+ wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
+ tar -xzf ${FILE} -C taglib
+ PKG_CONFIG_PREFIX=/tmp/taglib
+ echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
+ echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV
diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml
index 6cb54dbdb..760a0528b 100644
--- a/.github/actions/prepare-docker/action.yml
+++ b/.github/actions/prepare-docker/action.yml
@@ -53,13 +53,13 @@ runs:
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
- uses: docker/login-action@v4
+ uses: docker/login-action@v3
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v4
+ uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -67,18 +67,12 @@ 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"]
+ uses: docker/setup-buildx-action@v3
- name: Extract metadata for Docker image
id: meta
- uses: docker/metadata-action@v6
+ uses: docker/metadata-action@v5
with:
- github-token: ${{ inputs.github_token }}
labels: |
maintainer=deluan@navidrome.org
images: |
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..38b7b8a86 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@v3
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
@@ -19,7 +19,8 @@ jobs:
const pull_user_id = ${{github.event.sender.id}};
const issue_number = await (async () => {
- for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
+ const pulls = await github.pulls.list({owner, repo});
+ for await (const {data} of github.paginate.iterator(pulls)) {
for (const pull of data) {
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
return pull.number;
@@ -33,24 +34,21 @@ jobs:
return core.error(`No matching pull request found`);
}
- const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
- const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
- if (!downloadable.length) {
+ const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
+ 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 {data: comments} = await github.issues.listComments({repo, owner, issue_number});
+ 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});
+ await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
} else {
core.info(`Creating a comment`);
- await github.rest.issues.createComment({repo, owner, issue_number, body});
+ await github.issues.createComment({repo, owner, issue_number, body});
}
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index c1ba8714c..0767346fa 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -14,6 +14,7 @@ concurrency:
cancel-in-progress: true
env:
+ CROSS_TAGLIB_VERSION: "2.1.1-1"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
@@ -24,7 +25,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@v5
with:
fetch-depth: 0
fetch-tags: true
@@ -32,7 +33,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 +41,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 +63,17 @@ jobs:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@v5
- - uses: actions/setup-go@v6
+ - name: Download TagLib
+ uses: ./.github/actions/download-taglib
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"
+ version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
- uses: golangci/golangci-lint-action@v9
+ uses: golangci/golangci-lint-action@v8
with:
- version: ${{ steps.golangci-version.outputs.version }}
+ version: latest
problem-matchers: true
args: --timeout 2m
@@ -92,234 +88,25 @@ 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 ./...
- - name: Verify no changes from go generate
- run: |
- git status --porcelain
- if [ -n "$(git status --porcelain)" ]; then
- echo 'Generated code is out of date. Run "make gen" and commit the changes'
- 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@v5
- - uses: actions/setup-go@v6
+ - name: Download TagLib
+ uses: ./.github/actions/download-taglib
with:
- go-version-file: go.mod
+ version: ${{ env.CROSS_TAGLIB_VERSION }}
- 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 ndpgen
- run: |
- cd plugins/cmd/ndpgen
- go test -shuffle=on -v
- 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
- env:
- FFMPEG_VERSION: "7.1"
- FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
- steps:
- - uses: actions/checkout@v7
-
- - uses: actions/setup-go@v6
- with:
- go-version-file: go.mod
-
- - uses: msys2/setup-msys2@v2
- with:
- msystem: MINGW64
- install: mingw-w64-x86_64-gcc
- update: false
-
- - name: Add mingw64 to PATH
- shell: bash
- run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
-
- - name: Cache ffmpeg
- id: ffmpeg-cache
- uses: actions/cache@v6
- with:
- path: C:\ffmpeg
- key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
-
- - name: Download ffmpeg
- if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
- shell: pwsh
- run: |
- $asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
- $url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip"
- Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip
- Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted
- New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null
- Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin
- Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
-
- - name: Add ffmpeg to PATH
- shell: bash
- run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
-
- - name: Verify toolchain
- shell: pwsh
- run: |
- go version
- where.exe gcc
- gcc --version
- ffmpeg -version
- ffprobe -version
-
- - name: Download dependencies
- shell: bash
- run: go mod download
-
- name: Test
- shell: bash
- env:
- CGO_ENABLED: "1"
- run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
-
- - name: Test ndpgen
- shell: bash
run: |
- cd plugins/cmd/ndpgen
- go test -shuffle=on -v
- go build -o ndpgen.exe .
- ./ndpgen.exe --help
+ pkg-config --define-prefix --cflags --libs taglib # for debugging
+ go test -shuffle=on -tags netgo -race ./... -v
js:
name: Test JS code
@@ -327,7 +114,7 @@ jobs:
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -358,7 +145,7 @@ jobs:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@v5
- run: |
set -e
for file in resources/i18n/*.json; do
@@ -385,10 +172,10 @@ 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-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 ]
+ platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
runs-on: ubuntu-latest
env:
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
@@ -404,7 +191,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- - uses: actions/checkout@v7
+ - uses: actions/checkout@v5
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
@@ -416,7 +203,7 @@ jobs:
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build Binaries
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -427,24 +214,10 @@ jobs:
build-args: |
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"
+ CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@v5
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@@ -453,7 +226,7 @@ jobs:
- name: Build and push image by digest
id: push-image
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -462,6 +235,7 @@ jobs:
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
+ CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
outputs: |
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
@@ -474,7 +248,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@v5
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@@ -482,55 +256,18 @@ jobs:
if-no-files-found: error
retention-days: 1
- push-manifest-ghcr:
- name: Push to GHCR
- permissions:
- contents: read
- packages: write
+ push-manifest:
+ name: Push Docker manifest
runs-on: ubuntu-latest
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@v5
- name: Download digests
- uses: actions/download-artifact@v8
- with:
- path: /tmp/digests
- pattern: digests-*
- merge-multiple: true
-
- - name: Prepare Docker Buildx
- uses: ./.github/actions/prepare-docker
- id: docker
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Create manifest list and push to ghcr.io
- working-directory: /tmp/digests
- run: |
- docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io"))) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
- $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
-
- - name: Inspect image in ghcr.io
- run: |
- docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
-
- push-manifest-dockerhub:
- name: Push to Docker Hub
- runs-on: ubuntu-latest
- permissions:
- contents: read
- needs: [build, check-push-enabled]
- if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
- continue-on-error: true
- steps:
- - uses: actions/checkout@v7
-
- - name: Download digests
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@v6
with:
path: /tmp/digests
pattern: digests-*
@@ -545,32 +282,33 @@ jobs:
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
+ - name: Create manifest list and push to ghcr.io
+ working-directory: /tmp/digests
+ run: |
+ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
+
- name: Create manifest list and push to Docker Hub
- uses: nick-fields/retry@v4
- with:
- timeout_minutes: 5
- max_attempts: 3
- retry_wait_seconds: 30
- command: |
- cd /tmp/digests
- docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
- $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
+ working-directory: /tmp/digests
+ if: vars.DOCKER_HUB_REPO != ''
+ run: |
+ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
+
+ - name: Inspect image in ghcr.io
+ run: |
+ docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
- name: Inspect image in Docker Hub
+ if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
- cleanup-digests:
- name: Cleanup digest artifacts
- runs-on: ubuntu-latest
- needs: [push-manifest-ghcr, push-manifest-dockerhub]
- if: always() && needs.push-manifest-ghcr.result == 'success'
- steps:
- name: Delete unnecessary digest artifacts
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,9 +318,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@v5
- - uses: actions/download-artifact@v8
+ - uses: actions/download-artifact@v6
with:
path: ./binaries
pattern: navidrome-windows*
@@ -601,7 +339,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@v5
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@@ -614,12 +352,12 @@ jobs:
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@v5
with:
fetch-depth: 0
fetch-tags: true
- - uses: actions/download-artifact@v8
+ - uses: actions/download-artifact@v6
with:
path: ./binaries
pattern: navidrome-*
@@ -632,9 +370,9 @@ jobs:
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v7
+ uses: goreleaser/goreleaser-action@v6
with:
- version: '2.16.0'
+ version: '~> v2'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -645,7 +383,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@v5
with:
name: packages
path: dist/navidrome_0*
@@ -668,13 +406,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@v6
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@v5
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
diff --git a/.github/workflows/push-translations.sh b/.github/workflows/push-translations.sh
deleted file mode 100755
index be153eea8..000000000
--- a/.github/workflows/push-translations.sh
+++ /dev/null
@@ -1,138 +0,0 @@
-#!/bin/sh
-
-set -e
-
-I18N_DIR=resources/i18n
-
-# Normalize JSON for deterministic comparison:
-# remove empty/null attributes, sort keys alphabetically
-process_json() {
- jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
-}
-
-# Get list of all languages configured in the POEditor project
-get_language_list() {
- curl -s -X POST https://api.poeditor.com/v2/languages/list \
- -d api_token="${POEDITOR_APIKEY}" \
- -d id="${POEDITOR_PROJECTID}"
-}
-
-# Extract language name from the language list JSON given a language code
-get_language_name() {
- lang_code="$1"
- lang_list="$2"
- echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
-}
-
-# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
-get_lang_code() {
- filepath="$1"
- filename=$(basename "$filepath")
- echo "${filename%.*}"
-}
-
-# Export the current translation for a language from POEditor (v2 API)
-export_language() {
- lang_code="$1"
- response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
- -d api_token="${POEDITOR_APIKEY}" \
- -d id="${POEDITOR_PROJECTID}" \
- -d language="$lang_code" \
- -d type="key_value_json")
-
- url=$(echo "$response" | jq -r '.result.url')
- if [ -z "$url" ] || [ "$url" = "null" ]; then
- echo "Failed to export $lang_code: $response" >&2
- return 1
- fi
- echo "$url"
-}
-
-# Flatten nested JSON to POEditor languages/update format.
-# POEditor uses term + context pairs, where:
-# term = the leaf key name
-# context = the parent path as "key1"."key2"."key3" (empty for root keys)
-flatten_to_poeditor() {
- jq -c '[paths(scalars) as $p |
- {
- "term": ($p | last | tostring),
- "context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
- "translation": {"content": getpath($p)}
- }
- ]' "$1"
-}
-
-# Update translations for a language in POEditor via languages/update API
-update_language() {
- lang_code="$1"
- file="$2"
-
- flatten_to_poeditor "$file" > /tmp/poeditor_data.json
- response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
- -d api_token="${POEDITOR_APIKEY}" \
- -d id="${POEDITOR_PROJECTID}" \
- -d language="$lang_code" \
- --data-urlencode data@/tmp/poeditor_data.json)
- rm -f /tmp/poeditor_data.json
-
- status=$(echo "$response" | jq -r '.response.status')
- if [ "$status" != "success" ]; then
- echo "Failed to update $lang_code: $response" >&2
- return 1
- fi
-
- parsed=$(echo "$response" | jq -r '.result.translations.parsed')
- added=$(echo "$response" | jq -r '.result.translations.added')
- updated=$(echo "$response" | jq -r '.result.translations.updated')
- echo " Translations - parsed: $parsed, added: $added, updated: $updated"
-}
-
-# --- Main ---
-
-if [ $# -eq 0 ]; then
- echo "Usage: $0 [file2] ..."
- echo "No files specified. Nothing to do."
- exit 0
-fi
-
-lang_list=$(get_language_list)
-upload_count=0
-
-for file in "$@"; do
- if [ ! -f "$file" ]; then
- echo "Warning: File not found: $file, skipping"
- continue
- fi
-
- lang_code=$(get_lang_code "$file")
- lang_name=$(get_language_name "$lang_code" "$lang_list")
-
- if [ -z "$lang_name" ]; then
- echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
- continue
- fi
-
- echo "Processing $lang_name ($lang_code)..."
-
- # Export current state from POEditor
- url=$(export_language "$lang_code")
- curl -sSL "$url" -o poeditor_export.json
-
- # Normalize both files for comparison
- process_json "$file" > local_normalized.json
- process_json poeditor_export.json > remote_normalized.json
-
- # Compare normalized versions
- if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
- echo " No differences, skipping"
- else
- echo " Differences found, updating POEditor..."
- update_language "$lang_code" "$file"
- upload_count=$((upload_count + 1))
- fi
-
- rm -f poeditor_export.json local_normalized.json remote_normalized.json
-done
-
-echo ""
-echo "Done. Updated $upload_count translation(s) in POEditor."
diff --git a/.github/workflows/push-translations.yml b/.github/workflows/push-translations.yml
deleted file mode 100644
index 546c64114..000000000
--- a/.github/workflows/push-translations.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: POEditor export
-
-on:
- push:
- branches:
- - master
- paths:
- - 'resources/i18n/*.json'
-
-jobs:
- push-translations:
- runs-on: ubuntu-latest
- if: ${{ github.repository_owner == 'navidrome' }}
- steps:
- - uses: actions/checkout@v7
- with:
- fetch-depth: 2
-
- - name: Detect changed translation files
- id: changed
- run: |
- CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
- echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
- echo "Changed translation files: $CHANGED_FILES"
-
- - name: Push translations to POEditor
- if: ${{ steps.changed.outputs.files != '' }}
- env:
- POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
- POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
- run: |
- .github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 33c8fadbd..c8bf3ae7f 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- - uses: dessant/lock-threads@v6
+ - uses: dessant/lock-threads@v5
with:
process-only: 'issues, prs'
issue-inactive-days: 120
@@ -28,7 +28,7 @@ jobs:
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- - uses: actions/stale@v10
+ - uses: actions/stale@v9
with:
operations-per-run: 999
days-before-issue-stale: 180
diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml
index 5db32f3fa..69ca1cc94 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@v5
- name: Get updated translations
id: poeditor
env:
@@ -24,7 +24,7 @@ jobs:
git status --porcelain
git diff
- name: Create Pull Request
- uses: peter-evans/create-pull-request@v8
+ uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PAT }}
author: "navidrome-bot "
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..74d7ee46f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,34 +17,18 @@ master.zip
testDB
cache/*
*.swp
-coverage.out
dist
music
-music.old
*.db*
.gitinfo
docker-compose.yml
!contrib/docker-compose.yml
binaries
navidrome-*
-/ndpgen
AGENTS.md
.github/prompts
.github/instructions
.github/git-commit-instructions.md
*.exe
*.test
-*.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/
+*.wasm
\ No newline at end of file
diff --git a/.golangci.yml b/.golangci.yml
index 923c06c7a..996dafccb 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -2,19 +2,16 @@ version: "2"
run:
build-tags:
- netgo
- - sqlite_fts5
linters:
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- - containedctx
- copyloopvar
- dogsled
- durationcheck
- errorlint
- - forbidigo
- gocritic
- gocyclo
- goprintffuncname
@@ -28,9 +25,6 @@ linters:
disable:
- staticcheck
settings:
- errcheck:
- exclude-functions:
- - (*github.com/zeebo/xxh3.Hasher).Write
gocritic:
disable-all: true
enabled-checks:
@@ -41,29 +35,10 @@ 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
exclusions:
- rules:
- - linters:
- - gosec
- path: _test\.go
- text: "G703"
- - path: _test\.go
- linters:
- - containedctx
- - path-except: 'db/migrations/'
- linters:
- - forbidigo
generated: lax
presets:
- comments
@@ -74,9 +49,6 @@ linters:
- third_party$
- builtin$
- examples$
- - node_modules
- - _gen\.go$
- - .worktrees
formatters:
exclusions:
generated: lax
@@ -84,4 +56,3 @@ formatters:
- third_party$
- builtin$
- examples$
- - node_modules
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/CONTRIBUTING.md b/CONTRIBUTING.md
index 71c13b497..f2631f597 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
### Commit Conventions
Each commit message must adhere to the following format:
```
-(scope):
+(scope): -
[optional body]
```
diff --git a/Dockerfile b/Dockerfile
index 5fc0529f0..fb1cf997b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,10 +2,10 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
########################################################################################################################
### 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.19 AS xx-build
-# v1.9.0
-ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
+# v1.5.0
+ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
RUN apk add -U --no-cache git
RUN git clone https://github.com/tonistiigi/xx && \
@@ -24,9 +24,29 @@ RUN cd /out && \
FROM scratch AS xx
COPY --from=xx-build /out/ /usr/bin/
+########################################################################################################################
+### Get TagLib
+FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build
+ARG TARGETPLATFORM
+ARG CROSS_TAGLIB_VERSION=2.1.1-1
+ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
+
+# wget in busybox can't follow redirects
+RUN </dev/null || true
+ export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
+ 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 +104,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 -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,66 +120,28 @@ 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.19 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 && \
- 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*
+# Install ffmpeg and mpv
+RUN apk add -U --no-cache ffmpeg mpv sqlite
-COPY --from=mpv-stubs /out/ /usr/lib/
-RUN mpv --no-video --ao=null --version > /dev/null
-
-# Copy navidrome binary (musl build for Docker, enables native libwebp)
-COPY --from=build-alpine /out/navidrome /app/
+# Copy navidrome binary
+COPY --from=build /out/navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
+ENV GODEBUG="asyncpreemptoff=1"
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
WORKDIR /app
-ENV PATH="/app:${PATH}"
ENTRYPOINT ["/app/navidrome"]
diff --git a/Makefile b/Makefile
index f31d98a01..df8155f56 100644
--- a/Makefile
+++ b/Makefile
@@ -1,30 +1,22 @@
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
-comma:=,
-GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
-
-# Set global environment variables, required for most targets
-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
endif
-SUPPORTED_PLATFORMS ?= 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
+SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
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
+# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
+CROSS_TAGLIB_VERSION ?= 2.1.1-1
+GOLANGCI_LINT_VERSION ?= v2.5.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@@ -34,11 +26,11 @@ setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First In
.PHONY: setup
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
- npx foreman -j Procfile.dev -p 4533 start
+ ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
.PHONY: dev
server: check_go_env buildjs ##@Development Start the backend in development mode
- go tool reflex -d none -c reflex.conf
+ @ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
.PHONY: server
stop: ##@Development Stop development servers (UI and backend)
@@ -50,23 +42,19 @@ stop: ##@Development Stop development servers (UI and backend)
.PHONY: stop
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
- go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
+ go tool ginkgo watch -tags=netgo -notify ./...
.PHONY: watch
PKG ?= ./...
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
- go test -tags $(GO_BUILD_TAGS) $(PKG)
+ go test -tags netgo $(PKG)
.PHONY: test
-test-ndpgen: ##@Development Run tests for ndpgen plugin
- cd plugins/cmd/ndpgen && go test ./......
-.PHONY: test-ndpgen
-
-testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
+testall: test-race test-i18n test-js ##@Development Run Go and JS tests
.PHONY: testall
test-race: ##@Development Run Go tests with race detector
- go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
+ go test -tags netgo -race -shuffle=on ./...
.PHONY: test-race
test-js: ##@Development Run JS tests
@@ -79,8 +67,8 @@ test-i18n: ##@Development Validate all translations files
install-golangci-lint: ##@Development Install golangci-lint if not present
@INSTALL=false; \
- if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \
- CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
+ if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \
+ CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
@@ -96,47 +84,8 @@ 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
+ PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code
@@ -151,19 +100,9 @@ format: ##@Development Format code
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
- go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
+ go tool wire gen -tags=netgo ./...
.PHONY: wire
-gen: check_go_env api-gen ##@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
- go mod tidy -C plugins/pdk/go
-.PHONY: gen
-
snapshots: ##@Development Update (GoLang) Snapshot tests
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
.PHONY: snapshots
@@ -188,14 +127,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
.PHONY: setup-git
build: check_go_env buildjs ##@Build Build the project
- go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
+ go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
.PHONY: build
buildall: deprecated build
.PHONY: buildall
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
- go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
+ go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
.PHONY: debug-build
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
@@ -220,6 +159,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
--platform $(PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
+ --build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--output "./binaries" --target binary .
.PHONY: docker-build
@@ -231,6 +171,7 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro
--platform $(IMAGE_PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
+ --build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--tag $(DOCKER_TAG) .
.PHONY: docker-image
@@ -243,8 +184,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
@du -h binaries/msi/*.msi
.PHONY: docker-msi
-docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=
- @if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag="; exit 1; fi
+run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=
+ @if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag="; exit 1; fi
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
if [ -f navidrome.toml ]; then \
@@ -255,7 +196,7 @@ docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run
fi; \
fi; \
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
-.PHONY: docker-run
+.PHONY: run-docker
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
@@ -274,39 +215,6 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
.PHONY: get-music
-##########################################
-#### Worktrees
-
-WORKTREES_DIR := .worktrees
-
-wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1]
- @if [ -z "${name}" ]; then echo "Usage: make wt name= [go=1]"; exit 1; fi
- @mkdir -p $(WORKTREES_DIR)
- @echo "Creating worktree for branch '${name}'..."
- @git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \
- git worktree add $(WORKTREES_DIR)/${name} ${name}
- @if [ -n "${go}" ]; then \
- ./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \
- else \
- ./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \
- fi
- @echo "\nWorktree ready at $(WORKTREES_DIR)/${name}"
- @echo " cd $(WORKTREES_DIR)/${name}"
-.PHONY: wt
-
-rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name
- @if [ -z "${name}" ]; then echo "Usage: make rm-wt name="; exit 1; fi
- @if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi
- @echo "Removing worktree '${name}'..."
- @git worktree remove --force $(WORKTREES_DIR)/${name}
- @echo "Worktree '${name}' removed."
- @echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}"
-.PHONY: rm-wt
-
-ls-wt: ##@Worktrees List all active git worktrees
- @git worktree list
-.PHONY: ls-wt
-
##########################################
#### Miscellaneous
@@ -358,6 +266,24 @@ deprecated:
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
.PHONY: deprecated
+# Generate Go code from plugins/api/api.proto
+plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files
+ go generate ./plugins/...
+.PHONY: plugin-gen
+
+plugin-examples: check_go_env ##@Development Build all example plugins
+ $(MAKE) -C plugins/examples clean all
+.PHONY: plugin-examples
+
+plugin-clean: check_go_env ##@Development Clean all plugins
+ $(MAKE) -C plugins/examples clean
+ $(MAKE) -C plugins/testdata clean
+.PHONY: plugin-clean
+
+plugin-tests: check_go_env ##@Development Build all test plugins
+ $(MAKE) -C plugins/testdata clean all
+.PHONY: plugin-tests
+
.DEFAULT_GOAL := help
HELP_FUN = \
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
deleted file mode 100644
index 03f37af19..000000000
--- a/adapters/deezer/client.go
+++ /dev/null
@@ -1,239 +0,0 @@
-package deezer
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strconv"
- "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)
-}
-
-type httpDoer interface {
- Do(req *http.Request) (*http.Response, error)
-}
-
-type client struct {
- httpDoer httpDoer
- jwt jwtToken
-}
-
-func newClient(hc httpDoer) *client {
- return &client{
- httpDoer: hc,
- }
-}
-
-func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
- params := url.Values{}
- params.Add("q", name)
- params.Add("order", "RANKING")
- params.Add("limit", strconv.Itoa(limit))
- req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
- if err != nil {
- return nil, err
- }
- req.URL.RawQuery = params.Encode()
-
- var results SearchArtistResults
- err = c.makeRequest(req, &results)
- if err != nil {
- return nil, err
- }
-
- if len(results.Data) == 0 {
- return nil, agents.ErrNotFound
- }
- return results.Data, nil
-}
-
-func (c *client) makeRequest(req *http.Request, response any) error {
- log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
- resp, err := c.httpDoer.Do(req)
- if err != nil {
- return err
- }
-
- defer resp.Body.Close()
- data, err := io.ReadAll(resp.Body)
- if err != nil {
- 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 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) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
- req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil)
- if err != nil {
- return nil, err
- }
-
- var results RelatedArtists
- err = c.makeRequest(req, &results)
- if err != nil {
- return nil, err
- }
-
- return results.Data, nil
-}
-
-func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) {
- params := url.Values{}
- params.Add("limit", strconv.Itoa(limit))
- req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil)
- if err != nil {
- return nil, err
- }
- req.URL.RawQuery = params.Encode()
-
- var results TopTracks
- err = c.makeRequest(req, &results)
- if err != nil {
- return nil, err
- }
-
- return results.Data, nil
-}
-
-const pipeAPIURL = "https://pipe.deezer.com/api"
-
-var strictPolicy = bluemonday.StrictPolicy()
-
-func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) {
- jwt, err := c.getJWT(ctx)
- if err != nil {
- return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
- }
-
- query := map[string]any{
- "operationName": "ArtistBio",
- "variables": map[string]any{
- "artistId": strconv.Itoa(artistID),
- },
- "query": `query ArtistBio($artistId: String!) {
- artist(artistId: $artistId) {
- bio {
- full
- }
- }
- }`,
- }
-
- body, err := json.Marshal(query)
- if err != nil {
- return "", err
- }
-
- req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body))
- if err != nil {
- return "", err
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept-Language", lang)
- req.Header.Set("Authorization", "Bearer "+jwt)
-
- log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)
- resp, err := c.httpDoer.Do(req)
- if err != nil {
- return "", err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != 200 {
- return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
- }
-
- data, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", err
- }
-
- type graphQLResponse struct {
- Data struct {
- Artist struct {
- Bio struct {
- Full string `json:"full"`
- } `json:"bio"`
- } `json:"artist"`
- } `json:"data"`
- Errors []struct {
- Message string `json:"message"`
- }
- }
-
- var result graphQLResponse
- if err := json.Unmarshal(data, &result); err != nil {
- return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
- }
-
- if len(result.Errors) > 0 {
- var errs []error
- for m := range result.Errors {
- errs = append(errs, errors.New(result.Errors[m].Message))
- }
- err := errors.Join(errs...)
- return "", fmt.Errorf("deezer: GraphQL error: %w", err)
- }
-
- if result.Data.Artist.Bio.Full == "" {
- return "", errors.New("deezer: biography not found")
- }
-
- return cleanBio(result.Data.Artist.Bio.Full), nil
-}
-
-func cleanBio(bio string) string {
- bio = strings.ReplaceAll(bio, "
", "\n")
- return strictPolicy.Sanitize(bio)
-}
diff --git a/adapters/deezer/client_auth.go b/adapters/deezer/client_auth.go
deleted file mode 100644
index eb664c00b..000000000
--- a/adapters/deezer/client_auth.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package deezer
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "sync"
- "time"
-
- "github.com/lestrrat-go/jwx/v3/jwt"
- "github.com/navidrome/navidrome/log"
-)
-
-type jwtToken struct {
- token string
- expiresAt time.Time
- mu sync.RWMutex
-}
-
-func (j *jwtToken) get() (string, bool) {
- j.mu.RLock()
- defer j.mu.RUnlock()
- if time.Now().Before(j.expiresAt) {
- return j.token, true
- }
- return "", false
-}
-
-func (j *jwtToken) set(token string, expiresIn time.Duration) {
- j.mu.Lock()
- defer j.mu.Unlock()
- j.token = token
- j.expiresAt = time.Now().Add(expiresIn)
-}
-
-func (c *client) getJWT(ctx context.Context) (string, error) {
- // Check if we have a valid cached token
- if token, valid := c.jwt.get(); valid {
- return token, nil
- }
-
- // Fetch a new anonymous token
- req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil)
- if err != nil {
- return "", err
- }
- req.Header.Set("Accept", "application/json")
-
- resp, err := c.httpDoer.Do(req)
- if err != nil {
- return "", err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != 200 {
- return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status)
- }
-
- data, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", err
- }
-
- type authResponse struct {
- JWT string `json:"jwt"` //nolint:gosec
- }
-
- var result authResponse
- if err := json.Unmarshal(data, &result); err != nil {
- return "", fmt.Errorf("deezer: failed to parse auth response: %w", err)
- }
-
- if result.JWT == "" {
- return "", errors.New("deezer: no JWT token in response")
- }
-
- // Parse JWT to get actual expiration time
- token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false))
- if err != nil {
- return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err)
- }
-
- // Calculate TTL with a 1-minute buffer for clock skew and network delays
- expiresAt, ok := token.Expiration()
- if !ok || expiresAt.IsZero() {
- return "", errors.New("deezer: JWT token has no expiration time")
- }
-
- ttl := time.Until(expiresAt) - 1*time.Minute
- if ttl <= 0 {
- return "", errors.New("deezer: JWT token already expired or expires too soon")
- }
-
- c.jwt.set(result.JWT, ttl)
- log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl)
-
- return result.JWT, nil
-}
diff --git a/adapters/deezer/client_auth_test.go b/adapters/deezer/client_auth_test.go
deleted file mode 100644
index 005a84e1a..000000000
--- a/adapters/deezer/client_auth_test.go
+++ /dev/null
@@ -1,294 +0,0 @@
-package deezer
-
-import (
- "bytes"
- "context"
- "fmt"
- "io"
- "net/http"
- "sync"
- "time"
-
- "github.com/lestrrat-go/jwx/v3/jwt"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("JWT Authentication", func() {
- var httpClient *fakeHttpClient
- var client *client
- var ctx context.Context
-
- BeforeEach(func() {
- httpClient = &fakeHttpClient{}
- client = newClient(httpClient)
- ctx = context.Background()
- })
-
- Describe("getJWT", func() {
- Context("with a valid JWT response", func() {
- It("successfully fetches and caches a JWT token", func() {
- testJWT := createTestJWT(5 * time.Minute)
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
- })
-
- token, err := client.getJWT(ctx)
- Expect(err).To(BeNil())
- Expect(token).To(Equal(testJWT))
- })
-
- It("returns the cached token on subsequent calls", func() {
- testJWT := createTestJWT(5 * time.Minute)
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
- })
-
- // First call should fetch from API
- token1, err := client.getJWT(ctx)
- Expect(err).To(BeNil())
- Expect(token1).To(Equal(testJWT))
- Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))
-
- // Second call should return cached token without hitting API
- httpClient.lastRequest = nil // Clear last request to verify no new request is made
- token2, err := client.getJWT(ctx)
- Expect(err).To(BeNil())
- Expect(token2).To(Equal(testJWT))
- Expect(httpClient.lastRequest).To(BeNil()) // No new request made
- })
-
- It("parses the JWT expiration time correctly", func() {
- expectedExpiration := time.Now().Add(5 * time.Minute)
- testToken, err := jwt.NewBuilder().
- Expiration(expectedExpiration).
- Build()
- Expect(err).To(BeNil())
- testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
- Expect(err).To(BeNil())
-
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
- })
-
- token, err := client.getJWT(ctx)
- Expect(err).To(BeNil())
- Expect(token).ToNot(BeEmpty())
-
- // Verify the token is cached until close to expiration
- // The cache should expire 1 minute before the JWT expires
- expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
- Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
- })
- })
-
- Context("with JWT tokens that expire soon", func() {
- It("rejects tokens that expire in less than 1 minute", func() {
- // Create a token that expires in 30 seconds (less than 1-minute buffer)
- testJWT := createTestJWT(30 * time.Second)
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
- })
-
- _, err := client.getJWT(ctx)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
- })
-
- It("rejects already expired tokens", func() {
- // Create a token that expired 1 minute ago
- testJWT := createTestJWT(-1 * time.Minute)
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
- })
-
- _, err := client.getJWT(ctx)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
- })
-
- It("accepts tokens that expire in more than 1 minute", func() {
- // Create a token that expires in 2 minutes (just over the 1-minute buffer)
- testJWT := createTestJWT(2 * time.Minute)
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
- })
-
- token, err := client.getJWT(ctx)
- Expect(err).To(BeNil())
- Expect(token).ToNot(BeEmpty())
- })
- })
-
- Context("with invalid responses", func() {
- It("handles HTTP error responses", func() {
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 500,
- Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
- })
-
- _, err := client.getJWT(ctx)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
- })
-
- It("handles malformed JSON responses", func() {
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
- })
-
- _, err := client.getJWT(ctx)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
- })
-
- It("handles responses with empty JWT field", func() {
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
- })
-
- _, err := client.getJWT(ctx)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
- })
-
- It("handles invalid JWT tokens", func() {
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
- })
-
- _, err := client.getJWT(ctx)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
- })
-
- It("rejects JWT tokens without expiration", func() {
- // Create a JWT without expiration claim
- testToken, err := jwt.NewBuilder().
- Claim("custom", "value").
- Build()
- Expect(err).To(BeNil())
-
- // Verify token has no expiration
- _, hasExp := testToken.Expiration()
- Expect(hasExp).To(BeFalse())
-
- testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
- Expect(err).To(BeNil())
-
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
- })
-
- _, err = client.getJWT(ctx)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
- })
- })
-
- Context("token caching behavior", func() {
- It("fetches a new token when the cached token expires", func() {
- // First token expires in 5 minutes
- firstJWT := createTestJWT(5 * time.Minute)
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
- })
-
- token1, err := client.getJWT(ctx)
- Expect(err).To(BeNil())
- Expect(token1).To(Equal(firstJWT))
-
- // Manually expire the cached token
- client.jwt.expiresAt = time.Now().Add(-1 * time.Second)
-
- // Second token with different expiration (10 minutes)
- secondJWT := createTestJWT(10 * time.Minute)
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
- })
-
- token2, err := client.getJWT(ctx)
- Expect(err).To(BeNil())
- Expect(token2).To(Equal(secondJWT))
- Expect(token2).ToNot(Equal(token1))
- })
- })
- })
-
- Describe("jwtToken cache", func() {
- var cache *jwtToken
-
- BeforeEach(func() {
- cache = &jwtToken{}
- })
-
- It("returns false for expired tokens", func() {
- cache.set("test-token", -1*time.Second) // Already expired
- token, valid := cache.get()
- Expect(valid).To(BeFalse())
- Expect(token).To(BeEmpty())
- })
-
- It("returns true for valid tokens", func() {
- cache.set("test-token", 4*time.Minute)
- token, valid := cache.get()
- Expect(valid).To(BeTrue())
- Expect(token).To(Equal("test-token"))
- })
-
- It("is thread-safe for concurrent access", func() {
- wg := sync.WaitGroup{}
-
- // Writer goroutine
- wg.Go(func() {
- for i := range 100 {
- cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
- time.Sleep(1 * time.Millisecond)
- }
- })
-
- // Reader goroutine
- wg.Go(func() {
- for range 100 {
- cache.get()
- time.Sleep(1 * time.Millisecond)
- }
- })
-
- // Wait for both goroutines to complete
- wg.Wait()
-
- // Verify final state is valid
- token, valid := cache.get()
- Expect(valid).To(BeTrue())
- Expect(token).To(HavePrefix("token-"))
- })
- })
-})
-
-// createTestJWT creates a valid JWT token for testing purposes
-func createTestJWT(expiresIn time.Duration) string {
- token, err := jwt.NewBuilder().
- Expiration(time.Now().Add(expiresIn)).
- Build()
- if err != nil {
- panic(fmt.Sprintf("failed to create test JWT: %v", err))
- }
- signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
- if err != nil {
- panic(fmt.Sprintf("failed to sign test JWT: %v", err))
- }
- return string(signed)
-}
diff --git a/adapters/deezer/client_test.go b/adapters/deezer/client_test.go
deleted file mode 100644
index 84d981a76..000000000
--- a/adapters/deezer/client_test.go
+++ /dev/null
@@ -1,242 +0,0 @@
-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"
-)
-
-var _ = Describe("client", func() {
- var httpClient *fakeHttpClient
- var client *client
-
- BeforeEach(func() {
- httpClient = &fakeHttpClient{}
- client = newClient(httpClient)
- })
-
- Describe("ArtistImages", func() {
- It("returns artist images from a successful request", func() {
- f, err := os.Open("tests/fixtures/deezer.search.artist.json")
- Expect(err).To(BeNil())
- httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
-
- artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
- Expect(err).To(BeNil())
- Expect(artists).To(HaveLen(17))
- Expect(artists[0].Name).To(Equal("Michael Jackson"))
- Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
- })
-
- It("fails if artist was not found", func() {
- httpClient.mock("https://api.deezer.com/search/artist", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
- })
-
- _, 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")
- })
- })
-
- Describe("TopTracks", func() {
- It("returns top tracks with artist and album info from a successful request", func() {
- f, err := os.Open("tests/fixtures/deezer.artist.top.json")
- Expect(err).To(BeNil())
- httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200})
-
- tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5)
- Expect(err).To(BeNil())
- Expect(tracks).To(HaveLen(5))
-
- // Verify first track has all expected fields
- Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
- Expect(tracks[0].Artist.Name).To(Equal("Daft Punk"))
- Expect(tracks[0].Album.Title).To(Equal("Random Access Memories"))
-
- // Verify second track
- Expect(tracks[1].Title).To(Equal("One More Time"))
- Expect(tracks[1].Artist.Name).To(Equal("Daft Punk"))
- Expect(tracks[1].Album.Title).To(Equal("Discovery"))
- })
- })
-
- Describe("ArtistBio", func() {
- BeforeEach(func() {
- // Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
- testJWT := createTestJWT(5 * time.Minute)
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
- })
- })
-
- It("returns artist bio from a successful request", func() {
- f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
- Expect(err).To(BeNil())
- httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
-
- bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
- Expect(err).To(BeNil())
- Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
- Expect(bio).ToNot(ContainSubstring(""))
- Expect(bio).ToNot(ContainSubstring("
"))
- })
-
- It("uses the provided language", func() {
- f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
- Expect(err).To(BeNil())
- httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
-
- _, err = client.getArtistBio(GinkgoT().Context(), 27, "fr")
- Expect(err).To(BeNil())
- Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
- })
-
- It("includes the JWT token in the request", func() {
- f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
- Expect(err).To(BeNil())
- httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
-
- _, err = client.getArtistBio(GinkgoT().Context(), 27, "en")
- Expect(err).To(BeNil())
- // Verify that the Authorization header has the Bearer token format
- authHeader := httpClient.lastRequest.Header.Get("Authorization")
- Expect(authHeader).To(HavePrefix("Bearer "))
- Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
- })
-
- It("handles GraphQL errors", func() {
- errorResponse := `{
- "data": {
- "artist": {
- "bio": {
- "full": ""
- }
- }
- },
- "errors": [
- {
- "message": "Artist not found"
- },
- {
- "message": "Invalid artist ID"
- }
- ]
- }`
- httpClient.mock("https://pipe.deezer.com/api", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
- })
-
- _, err := client.getArtistBio(GinkgoT().Context(), 999, "en")
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("GraphQL error"))
- Expect(err.Error()).To(ContainSubstring("Artist not found"))
- Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
- })
-
- It("handles empty biography", func() {
- emptyBioResponse := `{
- "data": {
- "artist": {
- "bio": {
- "full": ""
- }
- }
- }
- }`
- httpClient.mock("https://pipe.deezer.com/api", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
- })
-
- _, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
- Expect(err).To(MatchError("deezer: biography not found"))
- })
-
- It("handles JWT token fetch failure", func() {
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 500,
- Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
- })
-
- _, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
- })
-
- It("handles JWT token that expires too soon", func() {
- // Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
- expiredJWT := createTestJWT(30 * time.Second)
- httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
- })
-
- _, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
- })
- })
-})
-
-type fakeHttpClient struct {
- responses map[string]*http.Response
- lastRequest *http.Request
-}
-
-func (c *fakeHttpClient) mock(url string, response http.Response) {
- if c.responses == nil {
- c.responses = make(map[string]*http.Response)
- }
- c.responses[url] = &response
-}
-
-func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
- c.lastRequest = req
- u := req.URL
- u.RawQuery = ""
- if resp, ok := c.responses[u.String()]; ok {
- return resp, nil
- }
- panic("URL not mocked: " + u.String())
-}
diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go
deleted file mode 100644
index d3570a29f..000000000
--- a/adapters/deezer/deezer.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package deezer
-
-import (
- "cmp"
- "context"
- "errors"
- "fmt"
- "slices"
- "strings"
-
- "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/cache"
- "github.com/navidrome/navidrome/utils/httpclient"
- "github.com/navidrome/navidrome/utils/slice"
-)
-
-const deezerAgentName = "deezer"
-const deezerApiPictureXlSize = 1000
-const deezerApiPictureBigSize = 500
-const deezerApiPictureMediumSize = 250
-const deezerApiPictureSmallSize = 56
-const deezerArtistSearchLimit = 50
-
-type deezerAgent struct {
- dataStore model.DataStore
- client *client
- languages []string
-}
-
-func deezerConstructor(dataStore model.DataStore) agents.Interface {
- agent := &deezerAgent{
- dataStore: dataStore,
- languages: conf.Server.Deezer.Languages,
- }
- httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
- cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
- agent.client = newClient(cachedHttpClient)
- return agent
-}
-
-func (s *deezerAgent) AgentName() string {
- return deezerAgentName
-}
-
-func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
- artist, err := s.searchArtist(ctx, name)
- if err != nil {
- if errors.Is(err, agents.ErrNotFound) {
- log.Warn(ctx, "Artist not found in deezer", "artist", name)
- } else {
- log.Error(ctx, "Error calling deezer", "artist", name, err)
- }
- return nil, err
- }
-
- var res []agents.ExternalImage
- possibleImages := []struct {
- URL string
- Size int
- }{
- {artist.PictureXl, deezerApiPictureXlSize},
- {artist.PictureBig, deezerApiPictureBigSize},
- {artist.PictureMedium, deezerApiPictureMediumSize},
- {artist.PictureSmall, deezerApiPictureSmallSize},
- }
- for _, imgData := range possibleImages {
- if imgData.URL != "" && !isPlaceholderPicture(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 err != nil {
- return nil, err
- }
-
- log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name)
- for i := range artists {
- log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link)
- if i > 2 {
- break
- }
- }
-
- // Deezer's RANKING order isn't reliable for homonyms: rank name matches
- // ahead of non-matches, prefer an exact-case match, then the most fans.
- rank := func(a Artist) int {
- switch {
- case a.Name == name:
- return 2
- case strings.EqualFold(a.Name, name):
- return 1
- default:
- return 0
- }
- }
- slices.SortFunc(artists, func(a, b Artist) int {
- return cmp.Or(
- cmp.Compare(rank(b), rank(a)),
- cmp.Compare(b.NbFan, a.NbFan),
- cmp.Compare(a.ID, b.ID),
- )
- })
- best := artists[0]
- if !strings.EqualFold(best.Name, name) {
- log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name)
- return nil, agents.ErrNotFound
- }
- log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
- return new(best), nil
-}
-
-func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
- artist, err := s.searchArtist(ctx, name)
- if err != nil {
- return nil, err
- }
-
- related, err := s.client.getRelatedArtists(ctx, artist.ID)
- if err != nil {
- return nil, err
- }
-
- res := slice.Map(related, func(r Artist) agents.Artist {
- return agents.Artist{
- Name: r.Name,
- }
- })
- if len(res) > limit {
- res = res[:limit]
- }
- return res, nil
-}
-
-func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
- artist, err := s.searchArtist(ctx, artistName)
- if err != nil {
- return nil, err
- }
-
- tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
- if err != nil {
- return nil, err
- }
-
- res := slice.Map(tracks, func(r Track) agents.Song {
- return agents.Song{
- Name: r.Title,
- Album: r.Album.Title,
- Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
- }
- })
- return res, nil
-}
-
-func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
- artist, err := s.searchArtist(ctx, name)
- if err != nil {
- return "", err
- }
-
- for _, lang := range s.languages {
- bio, err := s.client.getArtistBio(ctx, artist.ID, lang)
- if err == nil && bio != "" {
- return bio, nil
- }
- log.Debug(ctx, "Deezer/artist.bio returned empty/error, trying next language", "artist", name, "lang", lang, err)
- }
- return "", agents.ErrNotFound
-}
-
-func init() {
- conf.AddHook(func() {
- if conf.Server.Deezer.Enabled {
- agents.Register(deezerAgentName, deezerConstructor)
- }
- })
-}
diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go
deleted file mode 100644
index 82d02c244..000000000
--- a/adapters/deezer/deezer_test.go
+++ /dev/null
@@ -1,296 +0,0 @@
-package deezer
-
-import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "io"
- "net/http"
- "os"
- "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"
-)
-
-var _ = Describe("deezerAgent", func() {
- var ctx context.Context
-
- BeforeEach(func() {
- ctx = context.Background()
- DeferCleanup(configtest.SetupConfig())
- conf.Server.Deezer.Enabled = true
- })
-
- Describe("deezerConstructor", func() {
- It("uses configured languages", func() {
- conf.Server.Deezer.Languages = []string{"pt", "en"}
- agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
- Expect(agent.languages).To(Equal([]string{"pt", "en"}))
- })
- })
-
- 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
-
- BeforeEach(func() {
- httpClient = newLangAwareHttpClient()
-
- // Mock search artist (returns Michael Jackson)
- fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json")
- httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200}
-
- // Mock JWT token
- testJWT := createTestJWT(5 * time.Minute)
- httpClient.jwtResponse = &http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
- }
- })
-
- setupAgent := func(languages []string) {
- conf.Server.Deezer.Languages = languages
- agent = &deezerAgent{
- dataStore: &tests.MockDataStore{},
- client: newClient(httpClient),
- languages: languages,
- }
- }
-
- It("returns content in first language when available (1 bio API call)", func() {
- setupAgent([]string{"fr", "en"})
-
- // French biography available
- fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
- httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200}
-
- bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter"))
- Expect(httpClient.bioRequestCount).To(Equal(1))
- Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr"))
- })
-
- It("falls back to second language when first returns empty (2 bio API calls)", func() {
- setupAgent([]string{"ja", "en"})
-
- // Japanese returns empty biography
- fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
- httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
- // English returns full biography
- fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json")
- httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200}
-
- bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
- Expect(httpClient.bioRequestCount).To(Equal(2))
- Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja"))
- Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en"))
- })
-
- It("returns ErrNotFound when all languages return empty", func() {
- setupAgent([]string{"ja", "xx"})
-
- // Both languages return empty biography
- fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
- httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
- fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
- httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200}
-
- _, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
-
- Expect(err).To(MatchError(agents.ErrNotFound))
- Expect(httpClient.bioRequestCount).To(Equal(2))
- })
- })
-})
-
-// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header
-type langAwareHttpClient struct {
- searchResponse *http.Response
- jwtResponse *http.Response
- bioResponses map[string]*http.Response
- bioRequests []*http.Request
- bioRequestCount int
-}
-
-func newLangAwareHttpClient() *langAwareHttpClient {
- return &langAwareHttpClient{
- bioResponses: make(map[string]*http.Response),
- bioRequests: make([]*http.Request, 0),
- }
-}
-
-func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
- // Handle search artist request
- if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" {
- if c.searchResponse != nil {
- return c.searchResponse, nil
- }
- return &http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
- }, nil
- }
-
- // Handle JWT token request
- if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" {
- if c.jwtResponse != nil {
- return c.jwtResponse, nil
- }
- return &http.Response{
- StatusCode: 500,
- Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)),
- }, nil
- }
-
- // Handle bio request (GraphQL API)
- if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" {
- c.bioRequestCount++
- c.bioRequests = append(c.bioRequests, req)
- lang := req.Header.Get("Accept-Language")
- if resp, ok := c.bioResponses[lang]; ok {
- return resp, nil
- }
- // Return empty bio by default
- return &http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)),
- }, nil
- }
-
- panic("URL not mocked: " + req.URL.String())
-}
diff --git a/adapters/deezer/responses.go b/adapters/deezer/responses.go
deleted file mode 100644
index 6cc95dd4b..000000000
--- a/adapters/deezer/responses.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package deezer
-
-type SearchArtistResults struct {
- Data []Artist `json:"data"`
- Total int `json:"total"`
- Next string `json:"next"`
-}
-
-type Artist struct {
- ID int `json:"id"`
- Name string `json:"name"`
- Link string `json:"link"`
- Picture string `json:"picture"`
- PictureSmall string `json:"picture_small"`
- PictureMedium string `json:"picture_medium"`
- PictureBig string `json:"picture_big"`
- PictureXl string `json:"picture_xl"`
- NbAlbum int `json:"nb_album"`
- NbFan int `json:"nb_fan"`
- Radio bool `json:"radio"`
- Tracklist string `json:"tracklist"`
- Type string `json:"type"`
-}
-
-type errorResponse struct {
- Error *deezerError `json:"error"`
-}
-
-type RelatedArtists struct {
- Data []Artist `json:"data"`
- Total int `json:"total"`
-}
-
-type TopTracks struct {
- Data []Track `json:"data"`
- Total int `json:"total"`
- Next string `json:"next"`
-}
-
-type Track struct {
- ID int `json:"id"`
- Title string `json:"title"`
- Link string `json:"link"`
- Duration int `json:"duration"`
- Rank int `json:"rank"`
- Preview string `json:"preview"`
- Artist Artist `json:"artist"`
- Album Album `json:"album"`
- Contributors []Artist `json:"contributors"`
-}
-
-type Album struct {
- ID int `json:"id"`
- Title string `json:"title"`
- Cover string `json:"cover"`
- CoverSmall string `json:"cover_small"`
- CoverMedium string `json:"cover_medium"`
- CoverBig string `json:"cover_big"`
- CoverXl string `json:"cover_xl"`
- Tracklist string `json:"tracklist"`
- Type string `json:"type"`
-}
diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go
deleted file mode 100644
index 7ea98a442..000000000
--- a/adapters/gotaglib/gotaglib.go
+++ /dev/null
@@ -1,301 +0,0 @@
-// Package gotaglib provides an alternative metadata extractor using go-taglib,
-// a pure Go (WASM-based) implementation of TagLib.
-//
-// This extractor aims for parity with the CGO-based taglib extractor. It uses
-// TagLib's PropertyMap interface for standard tags. The File handle API provides
-// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes)
-// through a single file open operation.
-//
-// This extractor is registered under the name "taglib". It only works with a filesystem
-// (fs.FS) and does not support direct local file paths. Files returned by the filesystem
-// must implement io.ReadSeeker for go-taglib to read them.
-package gotaglib
-
-import (
- "errors"
- "fmt"
- "io"
- "io/fs"
- "runtime/debug"
- "strings"
- "time"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/core/storage/local"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model/metadata"
- "go.senan.xyz/taglib"
-)
-
-type extractor struct {
- fs fs.FS
-}
-
-func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
- results := make(map[string]metadata.Info)
- for _, path := range files {
- props, err := e.extractMetadata(path)
- if err != nil {
- continue
- }
- results[path] = *props
- }
- return results, nil
-}
-
-func (e extractor) Version() string {
- bi, ok := debug.ReadBuildInfo()
- if ok {
- for _, dep := range bi.Deps {
- if dep.Path == "go.senan.xyz/taglib" {
- if dep.Replace != nil {
- return dep.Replace.Version
- }
- return dep.Version
- }
- }
- }
- return "unknown"
-}
-
-func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
- // Recover from panics in the WASM runtime that can occur during any taglib
- // operation (opening, reading tags, or reading properties). This catches crashes
- // from malformed files or WASM runtime issues (e.g., wazero mmap failures on
- // hardened systems with MemoryDenyWriteExecute=true).
- debug.SetPanicOnFault(true)
- defer func() {
- if r := recover(); r != nil {
- log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
- debug.PrintStack()
- err = fmt.Errorf("WASM runtime panic: %v", r)
- }
- }()
-
- f, close, err := e.openFile(filePath)
- if err != nil {
- log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
- return nil, err
- }
- defer close()
-
- // Get all tags and properties in one go
- allTags := f.AllTags()
- props := f.Properties()
-
- // Map properties to AudioProperties
- ap := metadata.AudioProperties{
- Duration: props.Length.Round(time.Millisecond * 10),
- BitRate: int(props.Bitrate),
- Channels: int(props.Channels),
- SampleRate: int(props.SampleRate),
- BitDepth: int(props.BitsPerSample),
- Codec: props.Codec,
- }
-
- // Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
- normalizedTags := make(map[string][]string, len(allTags.Tags))
- for key, values := range allTags.Tags {
- lowerKey := strings.ToLower(key)
- normalizedTags[lowerKey] = values
- }
-
- // Process format-specific raw tags
- processRawTags(allTags, normalizedTags)
-
- // Parse track/disc totals from "N/Total" format
- parseTuple(normalizedTags, "track")
- parseTuple(normalizedTags, "disc")
-
- // Adjust some ID3 tags
- parseLyrics(normalizedTags)
- parseTIPL(normalizedTags)
- delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib
-
- // Determine if file has embedded picture
- hasPicture := len(props.Images) > 0
-
- return &metadata.Info{
- Tags: normalizedTags,
- AudioProperties: ap,
- HasPicture: hasPicture,
- }, nil
-}
-
-// openFile opens the file at filePath using the extractor's filesystem.
-// It returns a TagLib File handle and a cleanup function to close resources.
-func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
- // Open the file from the filesystem
- file, err := e.fs.Open(filePath)
- if err != nil {
- return nil, nil, err
- }
- rs, isSeekable := file.(io.ReadSeeker)
- if !isSeekable {
- file.Close()
- return nil, nil, errors.New("file is not seekable")
- }
- // WithFilename provides a format detection hint via the file extension,
- // since OpenStream alone relies on content-sniffing which fails for some files.
- f, err = taglib.OpenStream(rs,
- taglib.WithReadStyle(taglib.ReadStyleFast),
- taglib.WithFilename(filePath),
- )
- if err != nil {
- file.Close()
- return nil, nil, err
- }
- closeFunc = func() {
- f.Close()
- file.Close()
- }
- return f, closeFunc, nil
-}
-
-// parseTuple parses track/disc numbers in "N/Total" format and separates them.
-// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10".
-func parseTuple(tags map[string][]string, prop string) {
- tagName := prop + "number"
- tagTotal := prop + "total"
- if value, ok := tags[tagName]; ok && len(value) > 0 {
- parts := strings.Split(value[0], "/")
- tags[tagName] = []string{parts[0]}
- if len(parts) == 2 {
- tags[tagTotal] = []string{parts[1]}
- }
- }
-}
-
-// parseLyrics ensures lyrics tags have a language code.
-// If lyrics exist without a language code, they are moved to "lyrics:xxx".
-func parseLyrics(tags map[string][]string) {
- lyrics := tags["lyrics"]
- if len(lyrics) > 0 {
- tags["lyrics:xxx"] = lyrics
- delete(tags, "lyrics")
- }
-}
-
-// processRawTags processes format-specific raw tags based on the detected file format.
-// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes.
-func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
- switch allTags.Format {
- case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
- parseID3v2Frames(allTags.Raw, normalizedTags)
- case taglib.FormatMP4:
- parseMP4Atoms(allTags.Raw, normalizedTags)
- case taglib.FormatASF:
- parseASFAttributes(allTags.Raw, normalizedTags)
- }
-}
-
-// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes.
-// This extracts language-specific lyrics that the standard Tags() doesn't provide.
-func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
- // Process frames that have language-specific data
- for key, values := range rawFrames {
- lowerKey := strings.ToLower(key)
-
- // Handle USLT:xxx and SYLT:xxx (lyrics with language codes)
- if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
- parts := strings.SplitN(lowerKey, ":", 2)
- if len(parts) == 2 && parts[1] != "" {
- lang := parts[1]
- lyricsKey := "lyrics:" + lang
- tags[lyricsKey] = append(tags[lyricsKey], values...)
- }
- }
- }
-
- // If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics
- for key := range tags {
- if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
- delete(tags, "lyrics")
- break
- }
- }
-}
-
-const iTunesKeyPrefix = "----:com.apple.iTunes:"
-
-// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags.
-func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
- // Process all atoms and add them to tags
- for key, values := range rawAtoms {
- // Strip iTunes prefix and convert to lowercase
- normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
- normalizedKey = strings.ToLower(normalizedKey)
-
- // Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
- if _, exists := tags[normalizedKey]; !exists {
- tags[normalizedKey] = values
- }
- }
-}
-
-// parseASFAttributes processes ASF raw attributes to get WMA-specific tags.
-func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
- // Process all attributes and add them to tags
- for key, values := range rawAttrs {
- normalizedKey := strings.ToLower(key)
-
- // Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
- if _, exists := tags[normalizedKey]; !exists {
- tags[normalizedKey] = values
- }
- }
-}
-
-// These are the only roles we support, based on Picard's tag map:
-// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
-var tiplMapping = map[string]string{
- "arranger": "arranger",
- "engineer": "engineer",
- "producer": "producer",
- "mix": "mixer",
- "DJ-mix": "djmixer",
-}
-
-// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
-//
-// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
-//
-// and breaks it down into a map of roles and names, e.g.:
-//
-// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
-func parseTIPL(tags map[string][]string) {
- tipl := tags["tipl"]
- if len(tipl) == 0 {
- return
- }
- addRole := func(currentRole string, currentValue []string) {
- if currentRole != "" && len(currentValue) > 0 {
- role := tiplMapping[currentRole]
- tags[role] = append(tags[role], strings.Join(currentValue, " "))
- }
- }
- var currentRole string
- var currentValue []string
- for part := range strings.SplitSeq(tipl[0], " ") {
- if _, ok := tiplMapping[part]; ok {
- addRole(currentRole, currentValue)
- currentRole = part
- currentValue = nil
- continue
- }
- currentValue = append(currentValue, part)
- }
- addRole(currentRole, currentValue)
- delete(tags, "tipl")
-}
-
-var _ local.Extractor = (*extractor)(nil)
-
-func init() {
- local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
- return &extractor{fsys}
- })
- conf.AddHook(func() {
- log.Debug("go-taglib version", "version", extractor{}.Version())
- })
-}
diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go
deleted file mode 100644
index 0f68bd46b..000000000
--- a/adapters/lastfm/agent_test.go
+++ /dev/null
@@ -1,731 +0,0 @@
-package lastfm
-
-import (
- "bytes"
- "context"
- "errors"
- "io"
- "net/http"
- "net/url"
- "os"
- "strconv"
- "time"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
- "github.com/navidrome/navidrome/core/agents"
- "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"
-)
-
-const (
- lastfmError3 = `{"error":3,"message":"Invalid Method - No method with that name in this package","links":[]}`
- lastfmError6 = `{"error":6,"message":"The artist you supplied could not be found","links":[]}`
-)
-
-var _ = Describe("lastfmAgent", func() {
- var ds model.DataStore
- var ctx context.Context
- BeforeEach(func() {
- ds = &tests.MockDataStore{}
- ctx = context.Background()
- DeferCleanup(configtest.SetupConfig())
- conf.Server.LastFM.Enabled = true
- conf.Server.LastFM.ApiKey = "123"
- conf.Server.LastFM.Secret = "secret"
- })
- Describe("lastFMConstructor", func() {
- When("Agent is properly configured", func() {
- It("uses configured api key and languages", func() {
- conf.Server.LastFM.Languages = []string{"pt", "en"}
- agent := lastFMConstructor(ds)
- Expect(agent.apiKey).To(Equal("123"))
- Expect(agent.secret).To(Equal("secret"))
- Expect(agent.languages).To(Equal([]string{"pt", "en"}))
- })
- })
- When("Agent is disabled", func() {
- It("returns nil", func() {
- conf.Server.LastFM.Enabled = false
- Expect(lastFMConstructor(ds)).To(BeNil())
- })
- })
- When("ApiKey is empty", func() {
- It("returns nil", func() {
- conf.Server.LastFM.ApiKey = ""
- Expect(lastFMConstructor(ds)).To(BeNil())
- })
- })
- When("Secret is empty", func() {
- It("returns nil", func() {
- conf.Server.LastFM.Secret = ""
- Expect(lastFMConstructor(ds)).To(BeNil())
- })
- })
- })
-
- Describe("GetArtistBiography", func() {
- var agent *lastfmAgent
- var httpClient *tests.FakeHttpClient
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", httpClient)
- agent = lastFMConstructor(ds)
- agent.client = client
- })
-
- It("returns the biography", func() {
- f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente."))
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
- })
-
- It("returns an error if Last.fm call fails", func() {
- httpClient.Err = errors.New("error")
- _, err := agent.GetArtistBiography(ctx, "123", "U2", "")
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
- })
-
- It("returns an error if Last.fm call returns an error", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
- _, err := agent.GetArtistBiography(ctx, "123", "U2", "")
- Expect(err).To(HaveOccurred())
- 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() {
- Describe("GetArtistBiography", func() {
- var agent *lastfmAgent
- var httpClient *langAwareHttpClient
-
- BeforeEach(func() {
- httpClient = newLangAwareHttpClient()
- })
-
- It("returns content in first language when available (1 API call)", func() {
- conf.Server.LastFM.Languages = []string{"pt", "en"}
- agent = lastFMConstructor(ds)
- agent.client = newClient("API_KEY", "SECRET", httpClient)
-
- // Portuguese biography available
- f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
- httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200}
-
- bio, err := agent.GetArtistBiography(ctx, "123", "U2", "")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock"))
- Expect(httpClient.requestCount).To(Equal(1))
- Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt"))
- })
-
- It("falls back to second language when first returns empty (2 API calls)", func() {
- conf.Server.LastFM.Languages = []string{"ja", "en"}
- agent = lastFMConstructor(ds)
- agent.client = newClient("API_KEY", "SECRET", httpClient)
-
- // Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link)
- fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
- httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
- // English returns full biography
- fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json")
- httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
-
- bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band"))
- Expect(httpClient.requestCount).To(Equal(2))
- Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
- Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
- })
-
- It("returns ErrNotFound when all languages return empty", func() {
- conf.Server.LastFM.Languages = []string{"ja", "xx"}
- agent = lastFMConstructor(ds)
- agent.client = newClient("API_KEY", "SECRET", httpClient)
-
- // Both languages return empty/ignored biography (using actual Last.fm response format)
- fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
- httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
- // Second language also returns empty
- fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
- httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
-
- _, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
-
- Expect(err).To(MatchError(agents.ErrNotFound))
- Expect(httpClient.requestCount).To(Equal(2))
- })
- })
-
- Describe("GetAlbumInfo", func() {
- var agent *lastfmAgent
- var httpClient *langAwareHttpClient
-
- BeforeEach(func() {
- httpClient = newLangAwareHttpClient()
- })
-
- It("falls back to second language when first returns empty description (2 API calls)", func() {
- conf.Server.LastFM.Languages = []string{"ja", "en"}
- agent = lastFMConstructor(ds)
- agent.client = newClient("API_KEY", "SECRET", httpClient)
-
- // Japanese returns album without wiki/description (actual Last.fm response)
- fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
- httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
- // English returns album with description
- fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json")
- httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
-
- albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(albumInfo.Name).To(Equal("Dois"))
- Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio"))
- Expect(httpClient.requestCount).To(Equal(2))
- Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
- Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
- })
-
- It("returns album without description when all languages return empty", func() {
- conf.Server.LastFM.Languages = []string{"ja", "xx"}
- agent = lastFMConstructor(ds)
- agent.client = newClient("API_KEY", "SECRET", httpClient)
-
- // Both languages return album without description
- fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
- httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
- fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
- httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
-
- albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(albumInfo.Name).To(Equal("Dois"))
- Expect(albumInfo.Description).To(BeEmpty())
- Expect(httpClient.requestCount).To(Equal(2))
- })
- })
- })
-
- Describe("GetSimilarArtists", func() {
- var agent *lastfmAgent
- var httpClient *tests.FakeHttpClient
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", httpClient)
- agent = lastFMConstructor(ds)
- agent.client = client
- })
-
- It("returns similar artists", func() {
- f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- Expect(agent.GetSimilarArtists(ctx, "123", "U2", "", 2)).To(Equal([]agents.Artist{
- {Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"},
- {Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"},
- }))
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
- })
-
- It("returns an error if Last.fm call fails", func() {
- httpClient.Err = errors.New("error")
- _, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
- })
-
- It("returns an error if Last.fm call returns an error", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
- _, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
- })
- })
-
- Describe("GetArtistTopSongs", func() {
- var agent *lastfmAgent
- var httpClient *tests.FakeHttpClient
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", httpClient)
- agent = lastFMConstructor(ds)
- agent.client = client
- })
-
- It("returns top songs", func() {
- f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)).To(Equal([]agents.Song{
- {Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"},
- {Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"},
- }))
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
- })
-
- It("returns an error if Last.fm call fails", func() {
- httpClient.Err = errors.New("error")
- _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
- })
-
- It("returns an error if Last.fm call returns an error", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
- _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
- })
- })
-
- Describe("GetSimilarSongsByTrack", func() {
- var agent *lastfmAgent
- var httpClient *tests.FakeHttpClient
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", httpClient)
- agent = lastFMConstructor(ds)
- agent.client = client
- })
-
- It("returns similar songs", 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"}}},
- }))
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode"))
- })
-
- It("returns ErrNotFound when no similar songs found", func() {
- f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- _, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3)
- Expect(err).To(MatchError(agents.ErrNotFound))
- Expect(httpClient.RequestCount).To(Equal(1))
- })
-
- It("returns an error if Last.fm call fails", func() {
- httpClient.Err = errors.New("error")
- _, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- })
-
- It("returns an error if Last.fm call returns an error", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
- _, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- })
- })
-
- Describe("Scrobbling", func() {
- var agent *lastfmAgent
- var httpClient *tests.FakeHttpClient
- var track *model.MediaFile
- BeforeEach(func() {
- _ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
- httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", httpClient)
- agent = lastFMConstructor(ds)
- agent.client = client
- track = &model.MediaFile{
- ID: "123",
- Title: "Track Title",
- Album: "Track Album",
- Artist: "Track Artist",
- AlbumArtist: "Track AlbumArtist",
- TrackNumber: 1,
- Duration: 180,
- MbzRecordingID: "mbz-123",
- Participants: map[model.Role]model.ParticipantList{
- model.RoleArtist: []model.Participant{
- {Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
- {Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
- },
- model.RoleAlbumArtist: []model.Participant{
- {Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
- {Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
- },
- },
- }
- })
-
- Describe("NowPlaying", func() {
- It("calls Last.fm with correct params", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
-
- err := agent.NowPlaying(ctx, "user-1", track, 0)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
- body, _ := io.ReadAll(httpClient.SavedRequest.Body)
- sentParams, _ := url.ParseQuery(string(body))
- Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
- Expect(sentParams.Get("sk")).To(Equal("SK-1"))
- Expect(sentParams.Get("track")).To(Equal(track.Title))
- Expect(sentParams.Get("album")).To(Equal(track.Album))
- Expect(sentParams.Get("artist")).To(Equal(track.Artist))
- Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
- Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
- Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
- Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
- })
-
- It("returns ErrNotAuthorized if user is not linked", func() {
- err := agent.NowPlaying(ctx, "user-2", track, 0)
- Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
- })
-
- When("ScrobbleFirstArtistOnly is true", func() {
- BeforeEach(func() {
- conf.Server.LastFM.ScrobbleFirstArtistOnly = true
- })
-
- It("uses only the first artist", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
-
- err := agent.NowPlaying(ctx, "user-1", track, 0)
-
- Expect(err).ToNot(HaveOccurred())
- body, _ := io.ReadAll(httpClient.SavedRequest.Body)
- sentParams, _ := url.ParseQuery(string(body))
- Expect(sentParams.Get("artist")).To(Equal("First Artist"))
- Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
- })
- })
- })
-
- Describe("scrobble", func() {
- It("calls Last.fm with correct params", func() {
- ts := time.Now()
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
-
- err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
-
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
- body, _ := io.ReadAll(httpClient.SavedRequest.Body)
- sentParams, _ := url.ParseQuery(string(body))
- Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
- Expect(sentParams.Get("sk")).To(Equal("SK-1"))
- Expect(sentParams.Get("track")).To(Equal(track.Title))
- Expect(sentParams.Get("album")).To(Equal(track.Album))
- Expect(sentParams.Get("artist")).To(Equal(track.Artist))
- Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
- Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
- Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
- Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
- Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
- })
-
- When("ScrobbleFirstArtistOnly is true", func() {
- BeforeEach(func() {
- conf.Server.LastFM.ScrobbleFirstArtistOnly = true
- })
-
- It("uses only the first artist", func() {
- ts := time.Now()
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
-
- err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
-
- Expect(err).ToNot(HaveOccurred())
- body, _ := io.ReadAll(httpClient.SavedRequest.Body)
- sentParams, _ := url.ParseQuery(string(body))
- Expect(sentParams.Get("artist")).To(Equal("First Artist"))
- Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
- })
- })
-
- It("skips songs with less than 31 seconds", func() {
- track.Duration = 29
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
-
- err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
-
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest).To(BeNil())
- })
-
- It("returns ErrNotAuthorized if user is not linked", func() {
- err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
- Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
- })
-
- It("returns ErrRetryLater on error 11", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"error":11,"message":"Service Offline - This service is temporarily offline. Try again later."}`)),
- StatusCode: 400,
- }
-
- err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
- Expect(err).To(MatchError(scrobbler.ErrRetryLater))
- })
-
- It("returns ErrRetryLater on error 16", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"error":16,"message":"There was a temporary error processing your request. Please try again"}`)),
- StatusCode: 400,
- }
-
- err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
- 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`)),
- StatusCode: 500,
- }
-
- err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
- Expect(err).To(MatchError(scrobbler.ErrRetryLater))
- })
-
- It("returns ErrUnrecoverable on other errors", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"error":8,"message":"Operation failed - Something else went wrong"}`)),
- StatusCode: 400,
- }
-
- err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
- Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
- })
- })
- })
-
- Describe("GetAlbumInfo", func() {
- var agent *lastfmAgent
- var httpClient *tests.FakeHttpClient
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", httpClient)
- agent = lastFMConstructor(ds)
- agent.client = client
- })
-
- It("returns the biography", func() {
- f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
- Name: "Believe",
- MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
- Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob",
- 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"))
- })
-
- It("returns empty images if no images are available", func() {
- f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{
- Name: "The Definitive Less Damage And More Joy",
- URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy",
- }))
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy"))
- })
-
- It("returns an error if Last.fm call fails", func() {
- httpClient.Err = errors.New("error")
- _, 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())
- })
-
- It("returns an error if Last.fm call returns an error", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
- _, 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())
- })
-
- It("returns an error when Last.fm returns an error 6 (album not found)", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
- _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
- 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())
- })
- })
-
- Describe("GetArtistImages", func() {
- var agent *lastfmAgent
- var apiClient *tests.FakeHttpClient
- var httpClient *tests.FakeHttpClient
-
- BeforeEach(func() {
- apiClient = &tests.FakeHttpClient{}
- httpClient = &tests.FakeHttpClient{}
- client := newClient("API_KEY", "SECRET", apiClient)
- agent = lastFMConstructor(ds)
- agent.client = client
- agent.httpClient = httpClient
- })
-
- It("returns the artist image from the 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.html")
- httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
-
- images, err := agent.GetArtistImages(ctx, "123", "U2", "")
- Expect(err).ToNot(HaveOccurred())
- Expect(images).To(HaveLen(1))
- 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}
-
- fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html")
- httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
-
- images, err := agent.GetArtistImages(ctx, "123", "U2", "")
- Expect(err).ToNot(HaveOccurred())
- Expect(images).To(BeEmpty())
- })
-
- It("errors when the 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(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", "")
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("get artist info"))
- })
-
- It("returns error if scraper call fails", func() {
- fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
- apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
-
- httpClient.Err = errors.New("scraper error")
- _, err := agent.GetArtistImages(ctx, "123", "U2", "")
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("get artist url"))
- })
- })
-})
-
-// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter
-type langAwareHttpClient struct {
- responses map[string]http.Response
- requests []*http.Request
- requestCount int
-}
-
-func newLangAwareHttpClient() *langAwareHttpClient {
- return &langAwareHttpClient{
- responses: make(map[string]http.Response),
- requests: make([]*http.Request, 0),
- }
-}
-
-func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
- c.requestCount++
- c.requests = append(c.requests, req)
- lang := req.URL.Query().Get("lang")
- if resp, ok := c.responses[lang]; ok {
- return &resp, nil
- }
- // Return default empty response if no specific response is configured
- return &http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(`{}`)),
- }, nil
-}
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_test.go b/adapters/lastfm/client_test.go
deleted file mode 100644
index f72c84b91..000000000
--- a/adapters/lastfm/client_test.go
+++ /dev/null
@@ -1,312 +0,0 @@
-package lastfm
-
-import (
- "bytes"
- "context"
- "crypto/md5"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "os"
-
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("client", func() {
- var httpClient *tests.FakeHttpClient
- var client *client
-
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client = newClient("API_KEY", "SECRET", httpClient)
- })
-
- Describe("albumGetInfo", func() {
- It("returns an album on successful response", func() {
- f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
-
- album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
- Expect(err).To(BeNil())
- 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() {
- It("returns an artist for a successful response", func() {
- f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
-
- artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
- Expect(err).To(BeNil())
- Expect(artist.Name).To(Equal("U2"))
- 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`)),
- StatusCode: 500,
- }
-
- _, err := client.artistGetInfo(context.Background(), "U2", "pt")
- Expect(err).To(MatchError("last.fm http status: (500)"))
- })
-
- It("fails if Last.fm returns an http status != 200", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)),
- StatusCode: 400,
- }
-
- _, err := client.artistGetInfo(context.Background(), "U2", "pt")
- Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
- })
-
- It("fails if Last.fm returns an error", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)),
- StatusCode: 200,
- }
-
- _, err := client.artistGetInfo(context.Background(), "U2", "pt")
- Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
- })
-
- It("fails if HttpClient.Do() returns error", func() {
- httpClient.Err = errors.New("generic error")
-
- _, err := client.artistGetInfo(context.Background(), "U2", "pt")
- Expect(err).To(MatchError("generic error"))
- })
-
- It("fails if returned body is not a valid JSON", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`NOT_VALID_JSON`)),
- StatusCode: 200,
- }
-
- _, err := client.artistGetInfo(context.Background(), "U2", "pt")
- Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
- })
-
- })
-
- Describe("artistGetSimilar", func() {
- It("returns an artist for a successful response", func() {
- f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
-
- similar, err := client.artistGetSimilar(context.Background(), "U2", 2)
- Expect(err).To(BeNil())
- 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() {
- It("returns top tracks for a successful response", func() {
- f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
-
- top, err := client.artistGetTopTracks(context.Background(), "U2", 2)
- Expect(err).To(BeNil())
- 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() {
- It("returns similar tracks for a successful response", func() {
- f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
-
- similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5)
- Expect(err).To(BeNil())
- Expect(len(similar.Track)).To(Equal(5))
- Expect(similar.Track[0].Name).To(Equal("Dreaming of Me"))
- Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode"))
- Expect(similar.Track[0].Match).To(Equal(1.0))
- 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}
-
- similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3)
- Expect(err).To(BeNil())
- Expect(similar.Track).To(BeEmpty())
- })
- })
-
- Describe("GetToken", func() {
- It("returns a token when the request is successful", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"token":"TOKEN"}`)),
- StatusCode: 200,
- }
-
- Expect(client.GetToken(context.Background())).To(Equal("TOKEN"))
- queryParams := httpClient.SavedRequest.URL.Query()
- Expect(queryParams.Get("method")).To(Equal("auth.getToken"))
- Expect(queryParams.Get("format")).To(Equal("json"))
- Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
- Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
- })
- })
-
- Describe("getSession", func() {
- It("returns a session key when the request is successful", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"SESSION_KEY","subscriber":0}}`)),
- StatusCode: 200,
- }
-
- Expect(client.getSession(context.Background(), "TOKEN")).To(Equal("SESSION_KEY"))
- queryParams := httpClient.SavedRequest.URL.Query()
- Expect(queryParams.Get("method")).To(Equal("auth.getSession"))
- Expect(queryParams.Get("format")).To(Equal("json"))
- Expect(queryParams.Get("token")).To(Equal("TOKEN"))
- Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
- Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
- })
- })
-
- Describe("scrobble", func() {
- It("sends parameters in request body for POST", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"scrobbles":{"scrobble":{"ignoredMessage":{"code":"0"}},"@attr":{"accepted":1}}}`)),
- StatusCode: 200,
- }
-
- info := ScrobbleInfo{
- artist: "U2",
- track: "One",
- album: "Achtung Baby",
- trackNumber: 1,
- duration: 276,
- albumArtist: "U2",
- }
- err := client.scrobble(context.Background(), "SESSION_KEY", info)
- Expect(err).To(BeNil())
-
- req := httpClient.SavedRequest
- Expect(req.Method).To(Equal(http.MethodPost))
- Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
- Expect(req.URL.RawQuery).To(BeEmpty())
-
- body, _ := io.ReadAll(req.Body)
- bodyParams, _ := url.ParseQuery(string(body))
- Expect(bodyParams.Get("method")).To(Equal("track.scrobble"))
- Expect(bodyParams.Get("artist")).To(Equal("U2"))
- Expect(bodyParams.Get("track")).To(Equal("One"))
- Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
- Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
- Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
- })
- })
-
- Describe("updateNowPlaying", func() {
- It("sends parameters in request body for POST", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"nowplaying":{"ignoredMessage":{"code":"0"}}}`)),
- StatusCode: 200,
- }
-
- info := ScrobbleInfo{
- artist: "U2",
- track: "One",
- album: "Achtung Baby",
- trackNumber: 1,
- duration: 276,
- albumArtist: "U2",
- }
- err := client.updateNowPlaying(context.Background(), "SESSION_KEY", info)
- Expect(err).To(BeNil())
-
- req := httpClient.SavedRequest
- Expect(req.Method).To(Equal(http.MethodPost))
- Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
- Expect(req.URL.RawQuery).To(BeEmpty())
-
- body, _ := io.ReadAll(req.Body)
- bodyParams, _ := url.ParseQuery(string(body))
- Expect(bodyParams.Get("method")).To(Equal("track.updateNowPlaying"))
- Expect(bodyParams.Get("artist")).To(Equal("U2"))
- Expect(bodyParams.Get("track")).To(Equal("One"))
- Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
- Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
- Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
- })
- })
-
- Describe("sign", func() {
- It("adds an api_sig param with the signature", func() {
- params := url.Values{}
- params.Add("d", "444")
- params.Add("callback", "https://myserver.com")
- params.Add("a", "111")
- params.Add("format", "json")
- params.Add("c", "333")
- params.Add("b", "222")
- client.sign(params)
- Expect(params).To(HaveKey("api_sig"))
- sig := params.Get("api_sig")
- expected := fmt.Sprintf("%x", md5.Sum([]byte("a111b222c333d444SECRET")))
- Expect(sig).To(Equal(expected))
- })
- })
-})
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
deleted file mode 100644
index a59a5393f..000000000
--- a/adapters/listenbrainz/agent.go
+++ /dev/null
@@ -1,261 +0,0 @@
-package listenbrainz
-
-import (
- "context"
- "errors"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/consts"
- "github.com/navidrome/navidrome/core/agents"
- "github.com/navidrome/navidrome/core/scrobbler"
- "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"
-)
-
-const (
- listenBrainzAgentName = "listenbrainz"
- sessionKeyProperty = "ListenBrainzSessionKey"
-)
-
-type listenBrainzAgent struct {
- ds model.DataStore
- sessionKeys *agents.SessionKeys
- baseURL string
- client *client
-}
-
-func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
- l := &listenBrainzAgent{
- ds: ds,
- sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
- baseURL: conf.Server.ListenBrainz.BaseURL,
- }
- hc := httpclient.New(consts.DefaultHttpClientTimeOut)
- chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
- l.client = newClient(l.baseURL, chc)
- return l
-}
-
-func (l *listenBrainzAgent) AgentName() string {
- return listenBrainzAgentName
-}
-
-func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
- artistMBIDs := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
- return p.MbzArtistID
- })
- artistNames := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
- return p.Name
- })
- li := listenInfo{
- TrackMetadata: trackMetadata{
- ArtistName: track.Artist,
- TrackName: track.Title,
- ReleaseName: track.Album,
- AdditionalInfo: additionalInfo{
- SubmissionClient: consts.AppName,
- SubmissionClientVersion: consts.Version,
- TrackNumber: track.TrackNumber,
- ArtistNames: artistNames,
- ArtistMBIDs: artistMBIDs,
- RecordingMBID: track.MbzRecordingID,
- ReleaseMBID: track.MbzAlbumID,
- ReleaseGroupMBID: track.MbzReleaseGroupID,
- DurationMs: int(track.Duration * 1000),
- },
- },
- }
- return li
-}
-
-func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
- sk, err := l.sessionKeys.Get(ctx, userId)
- if err != nil || sk == "" {
- return errors.Join(err, scrobbler.ErrNotAuthorized)
- }
-
- li := l.formatListen(track)
- err = l.client.updateNowPlaying(ctx, sk, li)
- if err != nil {
- log.Warn(ctx, "ListenBrainz updateNowPlaying returned error", "track", track.Title, err)
- return errors.Join(err, scrobbler.ErrUnrecoverable)
- }
- return nil
-}
-
-func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
- sk, err := l.sessionKeys.Get(ctx, userId)
- if err != nil || sk == "" {
- return errors.Join(err, scrobbler.ErrNotAuthorized)
- }
-
- li := l.formatListen(&s.MediaFile)
- li.ListenedAt = int(s.TimeStamp.Unix())
- err = l.client.scrobble(ctx, sk, li)
-
- if err == nil {
- return nil
- }
- var lbErr *listenBrainzError
- isListenBrainzError := errors.As(err, &lbErr)
- if !isListenBrainzError {
- log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err)
- return errors.Join(err, scrobbler.ErrRetryLater)
- }
- if lbErr.Code == 500 || lbErr.Code == 503 {
- return errors.Join(err, scrobbler.ErrRetryLater)
- }
- return errors.Join(err, scrobbler.ErrUnrecoverable)
-}
-
-func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {
- sk, err := l.sessionKeys.Get(ctx, userId)
- return err == nil && sk != ""
-}
-
-func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
- if mbid == "" {
- return "", agents.ErrNotFound
- }
-
- url, err := l.client.getArtistUrl(ctx, mbid)
- if err != nil {
- return "", err
- }
- return url, nil
-}
-
-func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
- resp, err := l.client.getArtistTopSongs(ctx, mbid, count)
- if err != nil {
- return nil, err
- }
- if len(resp) == 0 {
- return nil, agents.ErrNotFound
- }
-
- res := make([]agents.Song, len(resp))
- for i, t := range resp {
- 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,
- }
- }
- 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
- }
-
- resp, err := l.client.getSimilarArtists(ctx, mbid, limit)
- if err != nil {
- return nil, err
- }
-
- if len(resp) == 0 {
- return nil, agents.ErrNotFound
- }
-
- artists := make([]agents.Artist, len(resp))
- for i, artist := range resp {
- artists[i] = agents.Artist{
- MBID: artist.MBID,
- Name: artist.Name,
- }
- }
-
- return artists, nil
-}
-
-func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) {
- if mbid == "" {
- return nil, agents.ErrNotFound
- }
-
- resp, err := l.client.getSimilarRecordings(ctx, mbid, limit)
- if err != nil {
- return nil, err
- }
-
- if len(resp) == 0 {
- return nil, agents.ErrNotFound
- }
-
- songs := make([]agents.Song, len(resp))
- for i, song := range resp {
- songs[i] = agents.Song{
- Album: song.ReleaseName,
- AlbumMBID: song.ReleaseMBID,
- Artists: []agents.Artist{{Name: song.Artist}},
- MBID: song.MBID,
- Name: song.Name,
- }
- }
-
- return songs, nil
-}
-
-func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
- return nil
-}
-
-func init() {
- conf.AddHook(func() {
- if conf.Server.ListenBrainz.Enabled {
- scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
- // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
- // See https://go.dev/doc/faq#nil_error
- a := listenBrainzConstructor(ds)
- if a != nil {
- return a
- }
- return nil
- })
-
- agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface {
- // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
- // See https://go.dev/doc/faq#nil_error
- a := listenBrainzConstructor(ds)
- if a != nil {
- return a
- }
- return nil
- })
- }
- })
-}
-
-var (
- _ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
- _ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
- _ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
- _ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
-)
diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go
deleted file mode 100644
index d2aa07f1a..000000000
--- a/adapters/listenbrainz/agent_test.go
+++ /dev/null
@@ -1,479 +0,0 @@
-package listenbrainz
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "os"
- "time"
-
- "github.com/navidrome/navidrome/consts"
- "github.com/navidrome/navidrome/core/agents"
- "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"
- . "github.com/onsi/gomega/gstruct"
-)
-
-var _ = Describe("listenBrainzAgent", func() {
- var ds model.DataStore
- var ctx context.Context
- var agent *listenBrainzAgent
- var httpClient *tests.FakeHttpClient
- var track *model.MediaFile
-
- BeforeEach(func() {
- ds = &tests.MockDataStore{}
- ctx = context.Background()
- _ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
- httpClient = &tests.FakeHttpClient{}
- agent = listenBrainzConstructor(ds)
- agent.client = newClient("http://localhost:8080", httpClient)
- track = &model.MediaFile{
- ID: "123",
- Title: "Track Title",
- Album: "Track Album",
- Artist: "Track Artist",
- TrackNumber: 1,
- MbzRecordingID: "mbz-123",
- MbzAlbumID: "mbz-456",
- MbzReleaseGroupID: "mbz-789",
- Duration: 142.2,
- Participants: map[model.Role]model.ParticipantList{
- model.RoleArtist: []model.Participant{
- {Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
- {Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
- },
- },
- }
- })
-
- Describe("formatListen", func() {
- It("constructs the listenInfo properly", func() {
- lr := agent.formatListen(track)
- Expect(lr).To(MatchAllFields(Fields{
- "ListenedAt": Equal(0),
- "TrackMetadata": MatchAllFields(Fields{
- "ArtistName": Equal(track.Artist),
- "TrackName": Equal(track.Title),
- "ReleaseName": Equal(track.Album),
- "AdditionalInfo": MatchAllFields(Fields{
- "SubmissionClient": Equal(consts.AppName),
- "SubmissionClientVersion": Equal(consts.Version),
- "TrackNumber": Equal(track.TrackNumber),
- "RecordingMBID": Equal(track.MbzRecordingID),
- "ReleaseMBID": Equal(track.MbzAlbumID),
- "ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
- "ArtistNames": ConsistOf("Artist 1", "Artist 2"),
- "ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
- "DurationMs": Equal(142200),
- }),
- }),
- }))
- })
- })
-
- Describe("NowPlaying", func() {
- It("updates NowPlaying successfully", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
-
- err := agent.NowPlaying(ctx, "user-1", track, 0)
- Expect(err).ToNot(HaveOccurred())
- })
-
- It("returns ErrNotAuthorized if user is not linked", func() {
- err := agent.NowPlaying(ctx, "user-2", track, 0)
- Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
- })
- })
-
- Describe("Scrobble", func() {
- var sc scrobbler.Scrobble
-
- BeforeEach(func() {
- sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
- })
-
- It("sends a Scrobble successfully", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).ToNot(HaveOccurred())
- })
-
- It("sets the Timestamp properly", func() {
- httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).ToNot(HaveOccurred())
-
- decoder := json.NewDecoder(httpClient.SavedRequest.Body)
- var lr listenBrainzRequestBody
- err = decoder.Decode(&lr)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
- })
-
- It("returns ErrNotAuthorized if user is not linked", func() {
- err := agent.Scrobble(ctx, "user-2", sc)
- Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
- })
-
- It("returns ErrRetryLater on error 503", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
- StatusCode: 503,
- }
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).To(MatchError(scrobbler.ErrRetryLater))
- })
-
- It("returns ErrRetryLater on error 500", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
- StatusCode: 500,
- }
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).To(MatchError(scrobbler.ErrRetryLater))
- })
-
- It("returns ErrRetryLater on http errors", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
- StatusCode: 500,
- }
-
- err := agent.Scrobble(ctx, "user-1", sc)
- Expect(err).To(MatchError(scrobbler.ErrRetryLater))
- })
-
- It("returns ErrUnrecoverable on other errors", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
- StatusCode: 400,
- }
-
- 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() {
- var agent *listenBrainzAgent
- var httpClient *tests.FakeHttpClient
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client := newClient("BASE_URL", httpClient)
- agent = listenBrainzConstructor(ds)
- agent.client = client
- })
-
- It("returns artist url when MBID present", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/"))
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
- })
-
- It("returns error when url not present", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
- })
-
- It("returns error when fetch calls fails", func() {
- httpClient.Err = errors.New("error")
- _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
- })
-
- It("returns error when ListenBrainz returns an error", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
- StatusCode: 400,
- }
- _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
- })
- })
-
- Describe("GetTopSongs", func() {
- var agent *listenBrainzAgent
- var httpClient *tests.FakeHttpClient
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client := newClient("BASE_URL", httpClient)
- agent = listenBrainzConstructor(ds)
- agent.client = client
- })
-
- It("returns error when fetch calls", func() {
- httpClient.Err = errors.New("error")
- _, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
- })
-
- It("returns an error on listenbrainz error", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
- StatusCode: 400,
- }
- _, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1"))
- })
-
- It("returns all tracks when asked", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2)
- 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: "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,
- },
- }))
- })
-
- It("returns only one track when prompted", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
- 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,
- },
- }))
- })
-
- 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() {
- var agent *listenBrainzAgent
- var httpClient *tests.FakeHttpClient
- baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids="
- mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
-
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client := newClient("BASE_URL", httpClient)
- agent = listenBrainzConstructor(ds)
- agent.client = client
- })
-
- It("returns error when fetch calls", func() {
- httpClient.Err = errors.New("error")
- _, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
- })
-
- It("returns an error on listenbrainz error", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
- StatusCode: 400,
- }
- _, err := agent.GetSimilarArtists(ctx, "", "", "1", 1)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
- })
-
- It("returns all data on call", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
-
- resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
- Expect(resp).To(Equal([]agents.Artist{
- {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
- {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"},
- }))
- })
-
- It("returns subset of data on call", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
-
- resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
- Expect(resp).To(Equal([]agents.Artist{
- {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
- }))
- })
- })
-
- Describe("GetSimilarTracks", func() {
- var agent *listenBrainzAgent
- var httpClient *tests.FakeHttpClient
- mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
- baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids="
-
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client := newClient("BASE_URL", httpClient)
- agent = listenBrainzConstructor(ds)
- agent.client = client
- })
-
- It("returns error when fetch calls", func() {
- httpClient.Err = errors.New("error")
- _, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
- })
-
- It("returns an error on listenbrainz error", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
- StatusCode: 400,
- }
- _, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1)
- Expect(err).To(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
- })
-
- It("returns all data on call", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
-
- resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- 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: "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,
- },
- }))
- })
-
- It("returns subset of data on call", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
-
- resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.RequestCount).To(Equal(1))
- 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,
- },
- }))
- })
- })
-})
diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go
deleted file mode 100644
index aae4fb51d..000000000
--- a/adapters/listenbrainz/client.go
+++ /dev/null
@@ -1,395 +0,0 @@
-package listenbrainz
-
-import (
- "bytes"
- "cmp"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "path"
- "slices"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/core/agents"
- "github.com/navidrome/navidrome/log"
-)
-
-const (
- lbzApiUrl = "https://api.listenbrainz.org/1/"
- 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")
-)
-
-type listenBrainzError struct {
- Code int
- Message string
-}
-
-func (e *listenBrainzError) Error() string {
- return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
-}
-
-type httpDoer interface {
- Do(req *http.Request) (*http.Response, error)
-}
-
-func newClient(baseURL string, hc httpDoer) *client {
- return &client{baseURL, hc}
-}
-
-type client struct {
- baseURL string
- hc httpDoer
-}
-
-type listenBrainzResponse struct {
- Code int `json:"code"`
- Message string `json:"message"`
- Error string `json:"error"`
- Status string `json:"status"`
- Valid bool `json:"valid"`
- UserName string `json:"user_name"`
-}
-
-type listenBrainzRequest struct {
- ApiKey string //nolint:gosec
- Body listenBrainzRequestBody
-}
-
-type listenBrainzRequestBody struct {
- ListenType listenType `json:"listen_type,omitempty"`
- Payload []listenInfo `json:"payload,omitempty"`
-}
-
-type listenType string
-
-const (
- Single listenType = "single"
- PlayingNow listenType = "playing_now"
-)
-
-type listenInfo struct {
- ListenedAt int `json:"listened_at,omitempty"`
- TrackMetadata trackMetadata `json:"track_metadata"`
-}
-
-type trackMetadata struct {
- ArtistName string `json:"artist_name,omitempty"`
- TrackName string `json:"track_name,omitempty"`
- ReleaseName string `json:"release_name,omitempty"`
- AdditionalInfo additionalInfo `json:"additional_info"`
-}
-
-type additionalInfo struct {
- SubmissionClient string `json:"submission_client,omitempty"`
- SubmissionClientVersion string `json:"submission_client_version,omitempty"`
- TrackNumber int `json:"tracknumber,omitempty"`
- ArtistNames []string `json:"artist_names,omitempty"`
- ArtistMBIDs []string `json:"artist_mbids,omitempty"`
- RecordingMBID string `json:"recording_mbid,omitempty"`
- ReleaseMBID string `json:"release_mbid,omitempty"`
- ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
- DurationMs int `json:"duration_ms,omitempty"`
-}
-
-func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
- r := &listenBrainzRequest{
- ApiKey: apiKey,
- }
- response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r)
- if err != nil {
- return nil, err
- }
- return response, nil
-}
-
-func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
- r := &listenBrainzRequest{
- ApiKey: apiKey,
- Body: listenBrainzRequestBody{
- ListenType: PlayingNow,
- Payload: []listenInfo{li},
- },
- }
-
- resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
- if err != nil {
- return err
- }
- if resp.Status != "ok" {
- log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
- }
- return nil
-}
-
-func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
- r := &listenBrainzRequest{
- ApiKey: apiKey,
- Body: listenBrainzRequestBody{
- ListenType: Single,
- Payload: []listenInfo{li},
- },
- }
- resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
- if err != nil {
- return err
- }
- if resp.Status != "ok" {
- log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
- }
- return nil
-}
-
-func (c *client) path(endpoint string) (string, error) {
- u, err := url.Parse(c.baseURL)
- if err != nil {
- return "", err
- }
- u.Path = path.Join(u.Path, endpoint)
- return u.String(), nil
-}
-
-func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
- b, _ := json.Marshal(r.Body)
- uri, err := c.path(endpoint)
- if err != nil {
- return nil, err
- }
- req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
- req.Header.Add("Content-Type", "application/json; charset=UTF-8")
-
- if r.ApiKey != "" {
- req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
- }
-
- log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
- resp, err := c.hc.Do(req)
- if err != nil {
- return nil, err
- }
-
- defer resp.Body.Close()
- if resp.StatusCode == http.StatusTooManyRequests {
- return nil, retryLaterErr(resp.Header)
- }
- decoder := json.NewDecoder(resp.Body)
-
- var response listenBrainzResponse
- jsonErr := decoder.Decode(&response)
- if resp.StatusCode != 200 && jsonErr != nil {
- return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
- }
- if jsonErr != nil {
- 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}
- }
-
- return &response, nil
-}
-
-type lbzHttpError struct {
- Code int `json:"code"`
- Error string `json:"error"`
-}
-
-func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) {
- req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil)
- req.Header.Add("Content-Type", "application/json; charset=UTF-8")
- req.URL.RawQuery = params.Encode()
-
- log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
- resp, err := c.hc.Do(req)
-
- if err != nil {
- return nil, err
- }
-
- // 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
- jsonErr := decoder.Decode(&lbzError)
-
- if jsonErr != nil {
- return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
- }
-
- return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error}
- }
-
- return resp, err
-}
-
-type artistMetadataResult struct {
- Rels struct {
- OfficialHomepage string `json:"official homepage,omitempty"`
- } `json:"rels,omitzero"`
-}
-
-func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) {
- params := url.Values{}
- params.Add("artist_mbids", mbid)
- resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params)
- if err != nil {
- return "", err
- }
-
- defer resp.Body.Close()
- decoder := json.NewDecoder(resp.Body)
-
- var response []artistMetadataResult
- jsonErr := decoder.Decode(&response)
- if jsonErr != nil {
- return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
- }
-
- if len(response) == 0 || response[0].Rels.OfficialHomepage == "" {
- return "", ErrorNotFound
- }
-
- return response[0].Rels.OfficialHomepage, nil
-}
-
-type trackInfo struct {
- ArtistName string `json:"artist_name"`
- ArtistMBIDs []string `json:"artist_mbids"`
- DurationMs uint32 `json:"length"`
- RecordingName string `json:"recording_name"`
- RecordingMbid string `json:"recording_mbid"`
- ReleaseName string `json:"release_name"`
- ReleaseMBID string `json:"release_mbid"`
-}
-
-func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) {
- resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{})
- if err != nil {
- return nil, err
- }
-
- defer resp.Body.Close()
- decoder := json.NewDecoder(resp.Body)
-
- var response []trackInfo
- jsonErr := decoder.Decode(&response)
- if jsonErr != nil {
- return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
- }
-
- if len(response) > count {
- return response[0:count], nil
- }
-
- return response, nil
-}
-
-type artist struct {
- MBID string `json:"artist_mbid"`
- Name string `json:"name"`
- Score int `json:"score"`
-}
-
-func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) {
- req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
- req.Header.Add("Content-Type", "application/json; charset=UTF-8")
- req.URL.RawQuery = url.Values{
- "artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
- }.Encode()
-
- log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
- resp, err := c.hc.Do(req)
-
- if err != nil {
- return nil, err
- }
-
- defer resp.Body.Close()
- decoder := json.NewDecoder(resp.Body)
-
- var artists []artist
- jsonErr := decoder.Decode(&artists)
- if jsonErr != nil {
- return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
- }
-
- if len(artists) > limit {
- return artists[:limit], nil
- }
-
- return artists, nil
-}
-
-type recording struct {
- MBID string `json:"recording_mbid"`
- Name string `json:"recording_name"`
- Artist string `json:"artist_credit_name"`
- ReleaseName string `json:"release_name"`
- ReleaseMBID string `json:"release_mbid"`
- Score int `json:"score"`
-}
-
-func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) {
- req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
- req.Header.Add("Content-Type", "application/json; charset=UTF-8")
- req.URL.RawQuery = url.Values{
- "recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
- }.Encode()
-
- log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
- resp, err := c.hc.Do(req)
-
- if err != nil {
- return nil, err
- }
-
- defer resp.Body.Close()
- decoder := json.NewDecoder(resp.Body)
-
- var recordings []recording
- jsonErr := decoder.Decode(&recordings)
- if jsonErr != nil {
- return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
- }
-
- // For whatever reason, labs API isn't guaranteed to give results in the proper order
- // and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json
- // generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30
- slices.SortFunc(recordings, func(a, b recording) int {
- return cmp.Or(
- cmp.Compare(b.Score, a.Score), // Sort by score descending
- cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates
- )
- })
-
- recordings = slices.CompactFunc(recordings, func(a, b recording) bool {
- return a.MBID == b.MBID
- })
-
- if len(recordings) > limit {
- return recordings[:limit], nil
- }
-
- return recordings, nil
-}
diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go
deleted file mode 100644
index ec0b0ac11..000000000
--- a/adapters/listenbrainz/client_test.go
+++ /dev/null
@@ -1,537 +0,0 @@
-package listenbrainz
-
-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"
-)
-
-var _ = Describe("client", func() {
- var httpClient *tests.FakeHttpClient
- var client *client
- BeforeEach(func() {
- httpClient = &tests.FakeHttpClient{}
- client = newClient("BASE_URL/", httpClient)
- })
-
- Describe("listenBrainzResponse", func() {
- It("parses a response properly", func() {
- var response listenBrainzResponse
- err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(response.Code).To(Equal(200))
- Expect(response.Message).To(Equal("Message"))
- Expect(response.UserName).To(Equal("UserName"))
- Expect(response.Valid).To(BeTrue())
- Expect(response.Status).To(Equal("ok"))
- Expect(response.Error).To(Equal("Error"))
- })
- })
-
- Describe("validateToken", func() {
- BeforeEach(func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
- StatusCode: 200,
- }
- })
-
- It("formats the request properly", func() {
- _, err := client.validateToken(context.Background(), "LB-TOKEN")
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
- Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
-
- It("parses and returns the response", func() {
- res, err := client.validateToken(context.Background(), "LB-TOKEN")
- Expect(err).ToNot(HaveOccurred())
- Expect(res.Valid).To(Equal(true))
- Expect(res.UserName).To(Equal("ListenBrainzUser"))
- })
- })
-
- Context("with listenInfo", func() {
- var li listenInfo
- BeforeEach(func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
- StatusCode: 200,
- }
- li = listenInfo{
- TrackMetadata: trackMetadata{
- ArtistName: "Track Artist",
- TrackName: "Track Title",
- ReleaseName: "Track Album",
- AdditionalInfo: additionalInfo{
- TrackNumber: 1,
- ArtistNames: []string{"Artist 1", "Artist 2"},
- ArtistMBIDs: []string{"mbz-789", "mbz-012"},
- RecordingMBID: "mbz-123",
- ReleaseMBID: "mbz-456",
- DurationMs: 142200,
- },
- },
- }
- })
-
- Describe("updateNowPlaying", func() {
- It("formats the request properly", func() {
- Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
- Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
-
- body, _ := io.ReadAll(httpClient.SavedRequest.Body)
- f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
- Expect(body).To(MatchJSON(f))
- })
- })
-
- Describe("scrobble", func() {
- BeforeEach(func() {
- li.ListenedAt = 1635000000
- })
-
- It("formats the request properly", func() {
- Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
- Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
-
- body, _ := io.ReadAll(httpClient.SavedRequest.Body)
- f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
- Expect(body).To(MatchJSON(f))
- })
- })
- })
-
- Context("getArtistUrl", func() {
- baseUrl := "https://api.listenbrainz.org/1/metadata/artist?"
- It("handles a malformed request with status code", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
- StatusCode: 400,
- }
- _, err := client.getArtistUrl(context.Background(), "1")
- Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid."))
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
-
- It("handles a malformed request without meaningful body", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(``)),
- StatusCode: 501,
- }
- _, err := client.getArtistUrl(context.Background(), "1")
- Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)"))
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
-
- It("It returns not found when the artist has no official homepage", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- _, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973")
- Expect(err.Error()).To(Equal("listenbrainz: not found"))
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
-
- It("It returns data when the artist has a homepage", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")
- Expect(err).ToNot(HaveOccurred())
- Expect(url).To(Equal("http://projectmili.com/"))
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
- })
-
- Context("getArtistTopSongs", func() {
- baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/"
-
- It("handles a malformed request with status code", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
- StatusCode: 400,
- }
- _, err := client.getArtistTopSongs(context.Background(), "1", 50)
- Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid"))
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
-
- It("handles a malformed request without standard body", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(``)),
- StatusCode: 500,
- }
- _, err := client.getArtistTopSongs(context.Background(), "1", 1)
- Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)"))
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
-
- It("It returns all tracks when given the opportunity", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5)
- Expect(err).ToNot(HaveOccurred())
- Expect(data).To(Equal([]trackInfo{
- {
- ArtistName: "Mili",
- ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
- DurationMs: 211912,
- RecordingName: "world.execute(me);",
- RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
- ReleaseName: "Miracle Milk",
- ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
- },
- {
- ArtistName: "Mili",
- ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
- DurationMs: 174000,
- RecordingName: "String Theocracy",
- RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
- ReleaseName: "String Theocracy",
- ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
- },
- }))
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
-
- It("It returns a subset of tracks when allowed", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
- Expect(err).ToNot(HaveOccurred())
- Expect(data).To(Equal([]trackInfo{
- {
- ArtistName: "Mili",
- ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
- DurationMs: 211912,
- RecordingName: "world.execute(me);",
- RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
- ReleaseName: "Miracle Milk",
- ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
- },
- }))
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
- })
-
- Context("getSimilarArtists", func() {
- var algorithm string
-
- BeforeEach(func() {
- algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
- DeferCleanup(configtest.SetupConfig())
- })
-
- getUrl := func(mbid string) string {
- return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid)
- }
-
- mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
-
- It("handles a malformed request with status code", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
- StatusCode: 400,
- }
- _, err := client.getSimilarArtists(context.Background(), "1", 2)
- Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
-
- It("handles real data properly", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- Expect(resp).To(Equal([]artist{
- {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
- {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
- }))
- })
-
- It("truncates data when requested", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- Expect(resp).To(Equal([]artist{
- {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
- }))
- })
-
- It("fetches a different endpoint when algorithm changes", func() {
- algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
- conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
-
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- Expect(resp).To(Equal([]artist{
- {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
- {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
- }))
- })
- })
-
- Context("getSimilarRecordings", func() {
- var algorithm string
-
- BeforeEach(func() {
- algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
- DeferCleanup(configtest.SetupConfig())
- })
-
- getUrl := func(mbid string) string {
- return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid)
- }
-
- mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
-
- It("handles a malformed request with status code", func() {
- httpClient.Res = http.Response{
- Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
- StatusCode: 400,
- }
- _, err := client.getSimilarRecordings(context.Background(), "1", 2)
- Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- })
-
- It("handles real data properly", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- resp, err := client.getSimilarRecordings(context.Background(), mbid, 2)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- Expect(resp).To(Equal([]recording{
- {
- MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
- Name: "Take On Me",
- Artist: "a‐ha",
- ReleaseName: "Hunting High and Low",
- ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
- Score: 124,
- },
- {
- MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
- Name: "Wake Me Up Before You Go‐Go",
- Artist: "Wham!",
- ReleaseName: "Make It Big",
- ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
- Score: 65,
- },
- }))
- })
-
- It("truncates data when requested", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- Expect(resp).To(Equal([]recording{
- {
- MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
- Name: "Take On Me",
- Artist: "a‐ha",
- ReleaseName: "Hunting High and Low",
- ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
- Score: 124,
- },
- }))
- })
-
- It("properly sorts by score and truncates duplicates", func() {
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- // There are actually 5 items. The dedup should happen FIRST
- resp, err := client.getSimilarRecordings(context.Background(), mbid, 4)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- Expect(resp).To(Equal([]recording{
- {
- MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
- Name: "Take On Me",
- Artist: "a‐ha",
- ReleaseName: "Hunting High and Low",
- ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
- Score: 124,
- },
- {
- MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5",
- Name: "Everybody Wants to Rule the World",
- Artist: "Tears for Fears",
- ReleaseName: "Songs From the Big Chair",
- ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597",
- Score: 68,
- },
- {
- MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
- Name: "Wake Me Up Before You Go‐Go",
- Artist: "Wham!",
- ReleaseName: "Make It Big",
- ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
- Score: 65,
- },
- {
- MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f",
- Name: "Tainted Love",
- Artist: "Soft Cell",
- ReleaseName: "Non-Stop Erotic Cabaret",
- ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1",
- Score: 61,
- },
- }))
- })
-
- It("uses a different algorithm when configured", func() {
- algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
- conf.Server.ListenBrainz.TrackAlgorithm = algorithm
-
- f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
- httpClient.Res = http.Response{Body: f, StatusCode: 200}
- resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
- Expect(err).ToNot(HaveOccurred())
- Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
- Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
- Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
- Expect(resp).To(Equal([]recording{
- {
- MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
- Name: "Take On Me",
- Artist: "a‐ha",
- ReleaseName: "Hunting High and Low",
- ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
- Score: 124,
- },
- }))
- })
- })
-
- 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/adapters/gotaglib/end_to_end_test.go b/adapters/taglib/end_to_end_test.go
similarity index 93%
rename from adapters/gotaglib/end_to_end_test.go
rename to adapters/taglib/end_to_end_test.go
index 0f9a90d94..e4d94bb24 100644
--- a/adapters/gotaglib/end_to_end_test.go
+++ b/adapters/taglib/end_to_end_test.go
@@ -1,4 +1,4 @@
-package gotaglib
+package taglib
import (
"io/fs"
@@ -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,11 +91,12 @@ 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() {
- e = &extractor{fs: os.DirFS(".")}
+ e = &extractor{}
})
Describe("ReplayGain", 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,
@@ -149,7 +151,11 @@ var _ = Describe("Extractor", func() {
unsSylt := makeLyrics("xxx", "unspecified SYLT")
unsUslt := makeLyrics("xxx", "unspecified")
- Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
+ // Why is the order inconsistent between runs? Nobody knows
+ Expect(lyrics).To(Or(
+ Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}),
+ Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}),
+ ))
})
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
diff --git a/adapters/taglib/get_filename.go b/adapters/taglib/get_filename.go
new file mode 100644
index 000000000..df7cab860
--- /dev/null
+++ b/adapters/taglib/get_filename.go
@@ -0,0 +1,9 @@
+//go:build !windows
+
+package taglib
+
+import "C"
+
+func getFilename(s string) *C.char {
+ return C.CString(s)
+}
diff --git a/adapters/taglib/get_filename_win.go b/adapters/taglib/get_filename_win.go
new file mode 100644
index 000000000..2093616c8
--- /dev/null
+++ b/adapters/taglib/get_filename_win.go
@@ -0,0 +1,96 @@
+//go:build windows
+
+package taglib
+
+// From https://github.com/orofarne/gowchar
+
+/*
+#include
+
+const size_t SIZEOF_WCHAR_T = sizeof(wchar_t);
+
+void gowchar_set (wchar_t *arr, int pos, wchar_t val)
+{
+ arr[pos] = val;
+}
+
+wchar_t gowchar_get (wchar_t *arr, int pos)
+{
+ return arr[pos];
+}
+*/
+import "C"
+
+import (
+ "fmt"
+ "unicode/utf16"
+ "unicode/utf8"
+)
+
+var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T)
+
+func getFilename(s string) *C.wchar_t {
+ wstr, _ := StringToWcharT(s)
+ return wstr
+}
+
+func StringToWcharT(s string) (*C.wchar_t, C.size_t) {
+ switch SIZEOF_WCHAR_T {
+ case 2:
+ return stringToWchar2(s) // Windows
+ case 4:
+ return stringToWchar4(s) // Unix
+ default:
+ panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T))
+ }
+ panic("?!!")
+}
+
+// Windows
+func stringToWchar2(s string) (*C.wchar_t, C.size_t) {
+ var slen int
+ s1 := s
+ for len(s1) > 0 {
+ r, size := utf8.DecodeRuneInString(s1)
+ if er, _ := utf16.EncodeRune(r); er == '\uFFFD' {
+ slen += 1
+ } else {
+ slen += 2
+ }
+ s1 = s1[size:]
+ }
+ slen++ // \0
+ res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
+ var i int
+ for len(s) > 0 {
+ r, size := utf8.DecodeRuneInString(s)
+ if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' {
+ C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1))
+ i++
+ C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2))
+ i++
+ } else {
+ C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
+ i++
+ }
+ s = s[size:]
+ }
+ C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
+ return (*C.wchar_t)(res), C.size_t(slen)
+}
+
+// Unix
+func stringToWchar4(s string) (*C.wchar_t, C.size_t) {
+ slen := utf8.RuneCountInString(s)
+ slen++ // \0
+ res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
+ var i int
+ for len(s) > 0 {
+ r, size := utf8.DecodeRuneInString(s)
+ C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
+ s = s[size:]
+ i++
+ }
+ C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
+ return (*C.wchar_t)(res), C.size_t(slen)
+}
diff --git a/adapters/taglib/taglib.go b/adapters/taglib/taglib.go
new file mode 100644
index 000000000..d32adf4ed
--- /dev/null
+++ b/adapters/taglib/taglib.go
@@ -0,0 +1,178 @@
+package taglib
+
+import (
+ "io/fs"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/core/storage/local"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model/metadata"
+)
+
+type extractor struct {
+ baseDir string
+}
+
+func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
+ results := make(map[string]metadata.Info)
+ for _, path := range files {
+ props, err := e.extractMetadata(path)
+ if err != nil {
+ continue
+ }
+ results[path] = *props
+ }
+ return results, nil
+}
+
+func (e extractor) Version() string {
+ return Version()
+}
+
+func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
+ fullPath := filepath.Join(e.baseDir, filePath)
+ tags, err := Read(fullPath)
+ if err != nil {
+ log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err)
+ return nil, err
+ }
+
+ // Parse audio properties
+ ap := metadata.AudioProperties{}
+ ap.BitRate = parseProp(tags, "__bitrate")
+ ap.Channels = parseProp(tags, "__channels")
+ ap.SampleRate = parseProp(tags, "__samplerate")
+ ap.BitDepth = parseProp(tags, "__bitspersample")
+ length := parseProp(tags, "__lengthinmilliseconds")
+ ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
+
+ // Extract basic tags
+ parseBasicTag(tags, "__title", "title")
+ parseBasicTag(tags, "__artist", "artist")
+ parseBasicTag(tags, "__album", "album")
+ parseBasicTag(tags, "__comment", "comment")
+ parseBasicTag(tags, "__genre", "genre")
+ parseBasicTag(tags, "__year", "year")
+ parseBasicTag(tags, "__track", "tracknumber")
+
+ // Parse track/disc totals
+ parseTuple := func(prop string) {
+ tagName := prop + "number"
+ tagTotal := prop + "total"
+ if value, ok := tags[tagName]; ok && len(value) > 0 {
+ parts := strings.Split(value[0], "/")
+ tags[tagName] = []string{parts[0]}
+ if len(parts) == 2 {
+ tags[tagTotal] = []string{parts[1]}
+ }
+ }
+ }
+ parseTuple("track")
+ parseTuple("disc")
+
+ // Adjust some ID3 tags
+ parseLyrics(tags)
+ parseTIPL(tags)
+ delete(tags, "tmcl") // TMCL is already parsed by TagLib
+
+ return &metadata.Info{
+ Tags: tags,
+ AudioProperties: ap,
+ HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true",
+ }, nil
+}
+
+// parseLyrics make sure lyrics tags have language
+func parseLyrics(tags map[string][]string) {
+ lyrics := tags["lyrics"]
+ if len(lyrics) > 0 {
+ tags["lyrics:xxx"] = lyrics
+ delete(tags, "lyrics")
+ }
+}
+
+// These are the only roles we support, based on Picard's tag map:
+// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
+var tiplMapping = map[string]string{
+ "arranger": "arranger",
+ "engineer": "engineer",
+ "producer": "producer",
+ "mix": "mixer",
+ "DJ-mix": "djmixer",
+}
+
+// parseProp parses a property from the tags map and sets it to the target integer.
+// It also deletes the property from the tags map after parsing.
+func parseProp(tags map[string][]string, prop string) int {
+ if value, ok := tags[prop]; ok && len(value) > 0 {
+ v, _ := strconv.Atoi(value[0])
+ delete(tags, prop)
+ return v
+ }
+ return 0
+}
+
+// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
+// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
+// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
+func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
+ basicValue := tags[basicName]
+ if len(basicValue) == 0 {
+ return
+ }
+ delete(tags, basicName)
+ if len(tags[tagName]) == 0 {
+ tags[tagName] = basicValue
+ }
+}
+
+// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
+//
+// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
+//
+// and breaks it down into a map of roles and names, e.g.:
+//
+// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
+func parseTIPL(tags map[string][]string) {
+ tipl := tags["tipl"]
+ if len(tipl) == 0 {
+ return
+ }
+
+ addRole := func(currentRole string, currentValue []string) {
+ if currentRole != "" && len(currentValue) > 0 {
+ role := tiplMapping[currentRole]
+ tags[role] = append(tags[role], strings.Join(currentValue, " "))
+ }
+ }
+
+ var currentRole string
+ var currentValue []string
+ for _, part := range strings.Split(tipl[0], " ") {
+ if _, ok := tiplMapping[part]; ok {
+ addRole(currentRole, currentValue)
+ currentRole = part
+ currentValue = nil
+ continue
+ }
+ currentValue = append(currentValue, part)
+ }
+ addRole(currentRole, currentValue)
+ delete(tags, "tipl")
+}
+
+var _ local.Extractor = (*extractor)(nil)
+
+func init() {
+ local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor {
+ // ignores fs, as taglib extractor only works with local files
+ return &extractor{baseDir}
+ })
+ conf.AddHook(func() {
+ log.Debug("TagLib version", "version", Version())
+ })
+}
diff --git a/adapters/gotaglib/gotaglib_suite_test.go b/adapters/taglib/taglib_suite_test.go
similarity index 74%
rename from adapters/gotaglib/gotaglib_suite_test.go
rename to adapters/taglib/taglib_suite_test.go
index cc7ddc471..2b26612cf 100644
--- a/adapters/gotaglib/gotaglib_suite_test.go
+++ b/adapters/taglib/taglib_suite_test.go
@@ -1,4 +1,4 @@
-package gotaglib
+package taglib
import (
"testing"
@@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
-func TestGoTagLib(t *testing.T) {
+func TestTagLib(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
- RunSpecs(t, "GoTagLib Suite")
+ RunSpecs(t, "TagLib Suite")
}
diff --git a/adapters/gotaglib/gotaglib_test.go b/adapters/taglib/taglib_test.go
similarity index 86%
rename from adapters/gotaglib/gotaglib_test.go
rename to adapters/taglib/taglib_test.go
index 05924914d..f24c0e839 100644
--- a/adapters/gotaglib/gotaglib_test.go
+++ b/adapters/taglib/taglib_test.go
@@ -1,11 +1,10 @@
-package gotaglib
+package taglib
import (
"io/fs"
"os"
"strings"
- "github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -15,7 +14,7 @@ var _ = Describe("Extractor", func() {
var e *extractor
BeforeEach(func() {
- e = &extractor{fs: os.DirFS(".")}
+ e = &extractor{}
})
Describe("Parse", func() {
@@ -81,11 +80,12 @@ var _ = Describe("Extractor", func() {
Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
- // TagLib 1.12 returns 18, previous versions return 39.
+ // TabLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
+ Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue())
})
@@ -106,7 +106,7 @@ var _ = Describe("Extractor", func() {
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
- HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
+ HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
))
Expect(m.Tags).To(Or(
@@ -128,17 +128,6 @@ var _ = Describe("Extractor", func() {
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
- // Still as of TagLib v2.2.1, TagLib only maps values in ID3, MP4, and ASF tags
- // to `originaldate`.
- if strings.HasSuffix(file, ".mp3") || strings.HasSuffix(file, ".wav") || strings.HasSuffix(file, ".aiff") || strings.HasSuffix(file, ".m4a") || strings.HasSuffix(file, ".wma") {
- Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
- }
- // MP3Tag sets `ORIGYEAR` in several formats for which it has no built-in mapping
- // for original release dates.
- Expect(m.Tags).To(Or(
- HaveKeyWithValue("origyear", []string{"1998-07-28"}),
- HaveKeyWithValue("----:com.apple.itunes:origyear", []string{"1998-07-28"}),
- ))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(Or(
@@ -185,9 +174,6 @@ var _ = Describe("Extractor", func() {
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
- // ffmpeg -f lavfi -i "sine=frequency=1100:duration=1" -c:a libopus test.opus (tags added via mutagen)
- Entry("correctly parses opus tags (#4998)", "test.opus", "1s", 1, 48000, 0, "+5.12 dB", "0.11345678", "+5.12 dB", "0.11345678", false, true),
-
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
@@ -214,9 +200,6 @@ var _ = Describe("Extractor", func() {
// Only run permission tests if we are not root
RegularUserContext("when run without root privileges", func() {
BeforeEach(func() {
- tests.SkipOnWindows("uses Unix file permission bits")
- // Use root fs for absolute paths in temp directory
- e = &extractor{fs: os.DirFS("/")}
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
@@ -229,25 +212,20 @@ var _ = Describe("Extractor", func() {
})
It("correctly handle unreadable file due to insufficient read permission", func() {
- // Strip leading slash for DirFS rooted at "/"
- _, err := e.extractMetadata(accessForbiddenFile[1:])
+ _, err := e.extractMetadata(accessForbiddenFile)
Expect(err).To(MatchError(os.ErrPermission))
})
It("skips the file if it cannot be read", func() {
- // Get current working directory to construct paths relative to root
- cwd, err := os.Getwd()
- Expect(err).ToNot(HaveOccurred())
- // Strip leading slash for DirFS rooted at "/"
files := []string{
- cwd[1:] + "/tests/fixtures/test.mp3",
- cwd[1:] + "/tests/fixtures/test.ogg",
- accessForbiddenFile[1:],
+ "tests/fixtures/test.mp3",
+ "tests/fixtures/test.ogg",
+ accessForbiddenFile,
}
mds, err := e.Parse(files...)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
- Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
+ Expect(mds).ToNot(HaveKey(accessForbiddenFile))
})
})
})
diff --git a/adapters/taglib/taglib_wrapper.cpp b/adapters/taglib/taglib_wrapper.cpp
new file mode 100644
index 000000000..2985e8f18
--- /dev/null
+++ b/adapters/taglib/taglib_wrapper.cpp
@@ -0,0 +1,299 @@
+#include
+#include
+
+#define TAGLIB_STATIC
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "taglib_wrapper.h"
+
+char has_cover(const TagLib::FileRef f);
+
+static char TAGLIB_VERSION[16];
+
+char* taglib_version() {
+ snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
+ return (char *)TAGLIB_VERSION;
+}
+
+int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
+ TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast);
+
+ if (f.isNull()) {
+ return TAGLIB_ERR_PARSE;
+ }
+
+ if (!f.audioProperties()) {
+ return TAGLIB_ERR_AUDIO_PROPS;
+ }
+
+ // Add audio properties to the tags
+ const TagLib::AudioProperties *props(f.audioProperties());
+ goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
+ goPutInt(id, (char *)"__bitrate", props->bitrate());
+ goPutInt(id, (char *)"__channels", props->channels());
+ goPutInt(id, (char *)"__samplerate", props->sampleRate());
+
+ // Extract bits per sample for supported formats
+ int bitsPerSample = 0;
+ if (const auto* apeProperties{ dynamic_cast(props) })
+ bitsPerSample = apeProperties->bitsPerSample();
+ else if (const auto* asfProperties{ dynamic_cast(props) })
+ bitsPerSample = asfProperties->bitsPerSample();
+ else if (const auto* flacProperties{ dynamic_cast(props) })
+ bitsPerSample = flacProperties->bitsPerSample();
+ else if (const auto* mp4Properties{ dynamic_cast(props) })
+ bitsPerSample = mp4Properties->bitsPerSample();
+ else if (const auto* wavePackProperties{ dynamic_cast(props) })
+ bitsPerSample = wavePackProperties->bitsPerSample();
+ else if (const auto* aiffProperties{ dynamic_cast(props) })
+ bitsPerSample = aiffProperties->bitsPerSample();
+ else if (const auto* wavProperties{ dynamic_cast(props) })
+ bitsPerSample = wavProperties->bitsPerSample();
+ else if (const auto* dsfProperties{ dynamic_cast(props) })
+ bitsPerSample = dsfProperties->bitsPerSample();
+
+ if (bitsPerSample > 0) {
+ goPutInt(id, (char *)"__bitspersample", bitsPerSample);
+ }
+
+ // Send all properties to the Go map
+ TagLib::PropertyMap tags = f.file()->properties();
+
+ // Make sure at least the basic properties are extracted
+ TagLib::Tag *basic = f.file()->tag();
+ if (!basic->isEmpty()) {
+ if (!basic->title().isEmpty()) {
+ tags.insert("__title", basic->title());
+ }
+ if (!basic->artist().isEmpty()) {
+ tags.insert("__artist", basic->artist());
+ }
+ if (!basic->album().isEmpty()) {
+ tags.insert("__album", basic->album());
+ }
+ if (!basic->comment().isEmpty()) {
+ tags.insert("__comment", basic->comment());
+ }
+ if (!basic->genre().isEmpty()) {
+ tags.insert("__genre", basic->genre());
+ }
+ if (basic->year() > 0) {
+ tags.insert("__year", TagLib::String::number(basic->year()));
+ }
+ if (basic->track() > 0) {
+ tags.insert("__track", TagLib::String::number(basic->track()));
+ }
+ }
+
+ TagLib::ID3v2::Tag *id3Tags = NULL;
+
+ // Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
+ TagLib::MPEG::File *mp3File(dynamic_cast(f.file()));
+ if (mp3File != NULL) {
+ id3Tags = mp3File->ID3v2Tag();
+ }
+
+ if (id3Tags == NULL) {
+ TagLib::RIFF::WAV::File *wavFile(dynamic_cast(f.file()));
+ if (wavFile != NULL && wavFile->hasID3v2Tag()) {
+ id3Tags = wavFile->ID3v2Tag();
+ }
+ }
+
+ if (id3Tags == NULL) {
+ TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast(f.file()));
+ if (aiffFile && aiffFile->hasID3v2Tag()) {
+ id3Tags = aiffFile->tag();
+ }
+ }
+
+ // Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
+ // with many players, so they will not be parsed
+
+ if (id3Tags != NULL) {
+ const auto &frames = id3Tags->frameListMap();
+
+ for (const auto &kv: frames) {
+ if (kv.first == "USLT") {
+ for (const auto &tag: kv.second) {
+ TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast(tag);
+ if (frame == NULL) continue;
+
+ tags.erase("LYRICS");
+
+ const auto bv = frame->language();
+ char language[4] = {'x', 'x', 'x', '\0'};
+ if (bv.size() == 3) {
+ strncpy(language, bv.data(), 3);
+ }
+
+ char *val = const_cast(frame->text().toCString(true));
+
+ goPutLyrics(id, language, val);
+ }
+ } else if (kv.first == "SYLT") {
+ for (const auto &tag: kv.second) {
+ TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast(tag);
+ if (frame == NULL) continue;
+
+ const auto bv = frame->language();
+ char language[4] = {'x', 'x', 'x', '\0'};
+ if (bv.size() == 3) {
+ strncpy(language, bv.data(), 3);
+ }
+
+ const auto format = frame->timestampFormat();
+ if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
+
+ for (const auto &line: frame->synchedText()) {
+ char *text = const_cast(line.text.toCString(true));
+ goPutLyricLine(id, language, text, line.time);
+ }
+ } else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
+ const int sampleRate = props->sampleRate();
+
+ if (sampleRate != 0) {
+ for (const auto &line: frame->synchedText()) {
+ const int timeInMs = (line.time * 1000) / sampleRate;
+ char *text = const_cast(line.text.toCString(true));
+ goPutLyricLine(id, language, text, timeInMs);
+ }
+ }
+ }
+ }
+ } else if (kv.first == "TIPL"){
+ if (!kv.second.isEmpty()) {
+ tags.insert(kv.first, kv.second.front()->toString());
+ }
+ }
+ }
+ }
+
+ // M4A may have some iTunes specific tags not captured by the PropertyMap interface
+ TagLib::MP4::File *m4afile(dynamic_cast(f.file()));
+ if (m4afile != NULL) {
+ const auto itemListMap = m4afile->tag()->itemMap();
+ for (const auto item: itemListMap) {
+ char *key = const_cast(item.first.toCString(true));
+ for (const auto value: item.second.toStringList()) {
+ char *val = const_cast(value.toCString(true));
+ goPutM4AStr(id, key, val);
+ }
+ }
+ }
+
+ // WMA/ASF files may have additional tags not captured by the PropertyMap interface
+ TagLib::ASF::File *asfFile(dynamic_cast(f.file()));
+ if (asfFile != NULL) {
+ const TagLib::ASF::Tag *asfTags{asfFile->tag()};
+ const auto itemListMap = asfTags->attributeListMap();
+ for (const auto item : itemListMap) {
+ char *key = const_cast(item.first.toCString(true));
+
+ for (auto j = item.second.begin();
+ j != item.second.end(); ++j) {
+
+ char *val = const_cast(j->toString().toCString(true));
+ goPutStr(id, key, val);
+ }
+ }
+ }
+
+ // Send all collected tags to the Go map
+ for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
+ ++i) {
+ char *key = const_cast(i->first.toCString(true));
+ for (TagLib::StringList::ConstIterator j = i->second.begin();
+ j != i->second.end(); ++j) {
+ char *val = const_cast((*j).toCString(true));
+ goPutStr(id, key, val);
+ }
+ }
+
+ // Cover art has to be handled separately
+ if (has_cover(f)) {
+ goPutStr(id, (char *)"has_picture", (char *)"true");
+ }
+
+ return 0;
+}
+
+// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise.
+char has_cover(const TagLib::FileRef f) {
+ char hasCover = 0;
+ // ----- MP3
+ if (TagLib::MPEG::File * mp3File{dynamic_cast(f.file())}) {
+ if (mp3File->ID3v2Tag()) {
+ const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
+ hasCover = !frameListMap["APIC"].isEmpty();
+ }
+ }
+ // ----- FLAC
+ else if (TagLib::FLAC::File * flacFile{dynamic_cast(f.file())}) {
+ hasCover = !flacFile->pictureList().isEmpty();
+ }
+ // ----- MP4
+ else if (TagLib::MP4::File * mp4File{dynamic_cast(f.file())}) {
+ auto &coverItem{mp4File->tag()->itemMap()["covr"]};
+ TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()};
+ hasCover = !coverArtList.isEmpty();
+ }
+ // ----- Ogg
+ else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast(f.file())}) {
+ hasCover = !vorbisFile->tag()->pictureList().isEmpty();
+ }
+ // ----- Opus
+ else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast(f.file())}) {
+ hasCover = !opusFile->tag()->pictureList().isEmpty();
+ }
+ // ----- WAV
+ else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast(f.file()) }) {
+ if (wavFile->hasID3v2Tag()) {
+ const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
+ hasCover = !frameListMap["APIC"].isEmpty();
+ }
+ }
+ // ----- AIFF
+ else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast(f.file())}) {
+ if (aiffFile->hasID3v2Tag()) {
+ const auto& frameListMap{ aiffFile->tag()->frameListMap() };
+ hasCover = !frameListMap["APIC"].isEmpty();
+ }
+ }
+ // ----- WMA
+ else if (TagLib::ASF::File * asfFile{dynamic_cast(f.file())}) {
+ const TagLib::ASF::Tag *tag{ asfFile->tag() };
+ hasCover = tag && tag->attributeListMap().contains("WM/Picture");
+ }
+ // ----- DSF
+ else if (TagLib::DSF::File * dsffile{ dynamic_cast(f.file())}) {
+ const TagLib::ID3v2::Tag *tag { dsffile->tag() };
+ hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
+ }
+ // ----- WAVPAK (APE tag)
+ else if (TagLib::WavPack::File * wvFile{dynamic_cast(f.file())}) {
+ if (wvFile->hasAPETag()) {
+ // This is the particular string that Picard uses
+ hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
+ }
+ }
+
+ return hasCover;
+}
diff --git a/adapters/taglib/taglib_wrapper.go b/adapters/taglib/taglib_wrapper.go
new file mode 100644
index 000000000..4a979920a
--- /dev/null
+++ b/adapters/taglib/taglib_wrapper.go
@@ -0,0 +1,157 @@
+package taglib
+
+/*
+#cgo !windows pkg-config: --define-prefix taglib
+#cgo windows pkg-config: taglib
+#cgo illumos LDFLAGS: -lstdc++ -lsendfile
+#cgo linux darwin CXXFLAGS: -std=c++11
+#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib
+#include
+#include
+#include
+#include "taglib_wrapper.h"
+*/
+import "C"
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "runtime/debug"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "unsafe"
+
+ "github.com/navidrome/navidrome/log"
+)
+
+const iTunesKeyPrefix = "----:com.apple.itunes:"
+
+func Version() string {
+ return C.GoString(C.taglib_version())
+}
+
+func Read(filename string) (tags map[string][]string, err error) {
+ // Do not crash on failures in the C code/library
+ debug.SetPanicOnFault(true)
+ defer func() {
+ if r := recover(); r != nil {
+ log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r)
+ err = fmt.Errorf("extractor: recovered from panic: %s", r)
+ }
+ }()
+
+ fp := getFilename(filename)
+ defer C.free(unsafe.Pointer(fp))
+ id, m, release := newMap()
+ defer release()
+
+ log.Trace("extractor: reading tags", "filename", filename, "map_id", id)
+ res := C.taglib_read(fp, C.ulong(id))
+ switch res {
+ case C.TAGLIB_ERR_PARSE:
+ // Check additional case whether the file is unreadable due to permission
+ file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600)
+ defer file.Close()
+
+ if os.IsPermission(fileErr) {
+ return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr)
+ } else if fileErr != nil {
+ return nil, fmt.Errorf("cannot parse file media file: %w", fileErr)
+ } else {
+ return nil, fmt.Errorf("cannot parse file media file")
+ }
+ case C.TAGLIB_ERR_AUDIO_PROPS:
+ return nil, fmt.Errorf("can't get audio properties from file")
+ }
+ if log.IsGreaterOrEqualTo(log.LevelDebug) {
+ j, _ := json.Marshal(m)
+ log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id)
+ } else {
+ log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id)
+ }
+
+ return m, nil
+}
+
+type tagMap map[string][]string
+
+var allMaps sync.Map
+var mapsNextID atomic.Uint32
+
+func newMap() (uint32, tagMap, func()) {
+ id := mapsNextID.Add(1)
+
+ m := tagMap{}
+ allMaps.Store(id, m)
+
+ return id, m, func() {
+ allMaps.Delete(id)
+ }
+}
+
+func doPutTag(id C.ulong, key string, val *C.char) {
+ if key == "" {
+ return
+ }
+
+ r, _ := allMaps.Load(uint32(id))
+ m := r.(tagMap)
+ k := strings.ToLower(key)
+ v := strings.TrimSpace(C.GoString(val))
+ m[k] = append(m[k], v)
+}
+
+//export goPutM4AStr
+func goPutM4AStr(id C.ulong, key *C.char, val *C.char) {
+ k := C.GoString(key)
+
+ // Special for M4A, do not catch keys that have no actual name
+ k = strings.TrimPrefix(k, iTunesKeyPrefix)
+ doPutTag(id, k, val)
+}
+
+//export goPutStr
+func goPutStr(id C.ulong, key *C.char, val *C.char) {
+ doPutTag(id, C.GoString(key), val)
+}
+
+//export goPutInt
+func goPutInt(id C.ulong, key *C.char, val C.int) {
+ valStr := strconv.Itoa(int(val))
+ vp := C.CString(valStr)
+ defer C.free(unsafe.Pointer(vp))
+ goPutStr(id, key, vp)
+}
+
+//export goPutLyrics
+func goPutLyrics(id C.ulong, lang *C.char, val *C.char) {
+ doPutTag(id, "lyrics:"+C.GoString(lang), val)
+}
+
+//export goPutLyricLine
+func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
+ language := C.GoString(lang)
+ line := C.GoString(text)
+ timeGo := int64(time)
+
+ ms := timeGo % 1000
+ timeGo /= 1000
+ sec := timeGo % 60
+ timeGo /= 60
+ minimum := timeGo % 60
+ formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line)
+
+ key := "lyrics:" + language
+
+ r, _ := allMaps.Load(uint32(id))
+ m := r.(tagMap)
+ k := strings.ToLower(key)
+ existing, ok := m[k]
+ if ok {
+ existing[0] += formattedLine
+ } else {
+ m[k] = []string{formattedLine}
+ }
+}
diff --git a/adapters/taglib/taglib_wrapper.h b/adapters/taglib/taglib_wrapper.h
new file mode 100644
index 000000000..c93f4c14a
--- /dev/null
+++ b/adapters/taglib/taglib_wrapper.h
@@ -0,0 +1,24 @@
+#define TAGLIB_ERR_PARSE -1
+#define TAGLIB_ERR_AUDIO_PROPS -2
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#ifdef WIN32
+#define FILENAME_CHAR_T wchar_t
+#else
+#define FILENAME_CHAR_T char
+#endif
+
+extern void goPutM4AStr(unsigned long id, char *key, char *val);
+extern void goPutStr(unsigned long id, char *key, char *val);
+extern void goPutInt(unsigned long id, char *key, int val);
+extern void goPutLyrics(unsigned long id, char *lang, char *val);
+extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time);
+int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
+char* taglib_version();
+
+#ifdef __cplusplus
+}
+#endif
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..fc0f22fba 100644
--- a/cmd/pls.go
+++ b/cmd/pls.go
@@ -6,21 +6,15 @@ 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/playlists"
+ "github.com/navidrome/navidrome/core/auth"
+ "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/utils/ioutils"
- "github.com/navidrome/navidrome/utils/slice"
- "github.com/navidrome/navidrome/utils/str"
+ "github.com/navidrome/navidrome/persistence"
"github.com/spf13/cobra"
)
@@ -29,7 +23,6 @@ var (
outputFile string
userID string
outputFormat string
- syncFlag bool
)
type displayPlaylist struct {
@@ -51,15 +44,6 @@ func init() {
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
plsCmd.AddCommand(listCommand)
-
- exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
- exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory")
- exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
- plsCmd.AddCommand(exportCommand)
-
- importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)")
- importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced")
- plsCmd.AddCommand(importCommand)
}
var (
@@ -68,7 +52,7 @@ var (
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
- runExporter(cmd.Context())
+ runExporter()
},
}
@@ -76,170 +60,89 @@ var (
Use: "list",
Short: "List playlists",
Run: func(cmd *cobra.Command, args []string) {
- runList(cmd.Context())
- },
- }
-
- exportCommand = &cobra.Command{
- Use: "export",
- Short: "Export playlists to M3U files",
- Long: "Export one or more Navidrome playlists to M3U files",
- Run: func(cmd *cobra.Command, args []string) {
- runExport(cmd.Context())
- },
- }
-
- importCommand = &cobra.Command{
- Use: "import [files...]",
- Short: "Import M3U playlists",
- Long: "Import one or more M3U files as Navidrome playlists",
- Args: cobra.MinimumNArgs(1),
- Run: func(cmd *cobra.Command, args []string) {
- runImport(cmd.Context(), args)
+ runList()
},
}
)
-func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists {
- options := model.QueryOptions{Sort: sort}
- if userID != "" {
- user, err := getUser(ctx, userID, ds)
- if err != nil {
- log.Fatal(ctx, "Error retrieving user", "username or id", userID)
- }
- options.Filters = squirrel.Eq{"owner_id": user.ID}
- }
- pls, err := ds.Playlist().GetAll(ctx, options)
- if err != nil {
- log.Fatal(ctx, "Failed to retrieve playlists", err)
- }
- return pls
-}
-
-func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
- playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
+func runExporter() {
+ sqlDB := db.Db()
+ ds := persistence.New(sqlDB)
+ ctx := auth.WithAdminUser(context.Background(), ds)
+ playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
- log.Fatal("Error retrieving playlist", "name", nameOrID, err)
+ log.Fatal("Error retrieving playlist", "name", playlistID, 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": playlistID}})
if err != nil {
- log.Fatal("Error retrieving playlist", "name", nameOrID, err)
+ log.Fatal("Error retrieving playlist", "name", playlistID, 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)
+ log.Fatal("Error retrieving playlist", "name", playlistID, err)
}
}
}
if playlist == nil {
- log.Fatal("Playlist not found", "name", nameOrID)
+ log.Fatal("Playlist not found", "name", playlistID)
}
- return playlist
-}
-
-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)
- return
- }
- if err := os.WriteFile(file, []byte(m3u), 0600); err != nil {
- log.Fatal("Error writing to the output file", "file", file, err)
- }
-}
-
-func runExport(ctx context.Context) {
- ds, ctx := getAdminContext(ctx)
-
- if playlistID != "" && outputFile == "" {
- playlist := findPlaylist(ctx, ds, playlistID)
- writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
+ pls := playlist.ToM3U8()
+ if outputFile == "-" || outputFile == "" {
+ println(pls)
return
}
- if outputFile == "" {
- log.Fatal("Output directory (-o) is required for bulk export or when filtering by user")
+ err = os.WriteFile(outputFile, []byte(pls), 0600)
+ if err != nil {
+ log.Fatal("Error writing to the output file", "file", outputFile, err)
}
-
- info, err := os.Stat(outputFile)
- if err != nil || !info.IsDir() {
- log.Fatal("Output path must be an existing directory", "path", outputFile)
- }
-
- if playlistID != "" {
- pls := findPlaylist(ctx, ds, playlistID)
- filename := str.SanitizeFilename(pls.Name) + ".m3u"
- path := filepath.Join(outputFile, filename)
- err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600)
- if err != nil {
- log.Fatal("Error writing playlist", "file", path, err)
- }
- fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
- return
- }
-
- allPls := fetchPlaylists(ctx, ds, "name")
-
- nameCounts := make(map[string]int)
- for _, pls := range allPls {
- nameCounts[str.SanitizeFilename(pls.Name)]++
- }
-
- exported := 0
- for _, pls := range allPls {
- plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
- if err != nil {
- log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
- continue
- }
-
- sanitized := str.SanitizeFilename(pls.Name)
- filename := sanitized + ".m3u"
- if nameCounts[sanitized] > 1 {
- shortID := pls.ID
- if len(shortID) > 6 {
- shortID = shortID[:6]
- }
- filename = sanitized + "_" + shortID + ".m3u"
- }
-
- path := filepath.Join(outputFile, filename)
- err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600)
- if err != nil {
- log.Error("Error writing playlist", "file", path, err)
- continue
- }
- fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
- exported++
- }
- fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile)
}
-func runList(ctx context.Context) {
+func runList() {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
- ds, ctx := getAdminContext(ctx)
- allPls := fetchPlaylists(ctx, ds, "owner_name")
+ sqlDB := db.Db()
+ ds := persistence.New(sqlDB)
+ ctx := auth.WithAdminUser(context.Background(), ds)
+
+ options := model.QueryOptions{Sort: "owner_name"}
+
+ if userID != "" {
+ user, err := ds.User(ctx).FindByUsername(userID)
+
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ log.Fatal("Error retrieving user by name", "name", userID, err)
+ }
+
+ if errors.Is(err, model.ErrNotFound) {
+ user, err = ds.User(ctx).Get(userID)
+ if err != nil {
+ log.Fatal("Error retrieving user by id", "id", userID, err)
+ }
+ }
+
+ options.Filters = squirrel.Eq{"owner_id": user.ID}
+ }
+
+ playlists, err := ds.Playlist(ctx).GetAll(options)
+ if err != nil {
+ log.Fatal(ctx, "Failed to retrieve playlists", err)
+ }
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
- for _, playlist := range allPls {
+ for _, playlist := range playlists {
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
}
w.Flush()
} else {
- display := make(displayPlaylists, len(allPls))
- for idx, playlist := range allPls {
+ display := make(displayPlaylists, len(playlists))
+ for idx, playlist := range playlists {
display[idx].Id = playlist.ID
display[idx].Name = playlist.Name
display[idx].OwnerId = playlist.OwnerID
@@ -251,62 +154,3 @@ func runList(ctx context.Context) {
fmt.Printf("%s\n", j)
}
}
-
-func runImport(ctx context.Context, files []string) {
- ds, ctx := getAdminContext(ctx)
-
- if userID != "" {
- user, err := getUser(ctx, userID, ds)
- if err != nil {
- log.Fatal(ctx, "Error retrieving user", "username or id", userID)
- }
- ctx = request.WithUser(ctx, *user)
- }
-
- pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
-
- for _, file := range files {
- absPath, err := filepath.Abs(file)
- if err != nil {
- log.Error("Error resolving path", "file", file, err)
- fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err)
- continue
- }
-
- totalLines := countM3UTrackLines(absPath)
-
- imported, err := pls.ImportFile(ctx, absPath, syncFlag)
- if err != nil {
- log.Error("Error importing playlist", "file", absPath, err)
- fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err)
- continue
- }
-
- matched := len(imported.Tracks)
- if totalLines > 0 {
- notFound := totalLines - matched
- fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound)
- } else {
- fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched)
- }
- }
-}
-
-func countM3UTrackLines(path string) int {
- file, err := os.Open(path)
- if err != nil {
- return 0
- }
- defer file.Close()
-
- count := 0
- reader := ioutils.UTF8Reader(file)
- for line := range slice.LinesFrom(reader) {
- line = strings.TrimSpace(line)
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- count++
- }
- return count
-}
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
index 7b8a9a393..0f3b66078 100644
--- a/cmd/plugin.go
+++ b/cmd/plugin.go
@@ -1,557 +1,716 @@
package cmd
import (
- "context"
- "encoding/csv"
- "encoding/json"
+ "cmp"
+ "crypto/sha256"
+ "encoding/hex"
"fmt"
"io"
"os"
- "strconv"
+ "path/filepath"
"strings"
+ "text/tabwriter"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins"
+ "github.com/navidrome/navidrome/plugins/schema"
+ "github.com/navidrome/navidrome/utils"
+ "github.com/navidrome/navidrome/utils/slice"
"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
+const (
+ pluginPackageExtension = ".ndp"
+ pluginDirPermissions = 0700
+ pluginFilePermissions = 0600
)
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{
+ pluginCmd := &cobra.Command{
Use: "plugin",
- Short: "Manage and inspect plugins",
- Long: "List, inspect, enable, disable, configure, rescan, and validate plugins",
+ Short: "Manage Navidrome plugins",
+ Long: "Commands for managing Navidrome plugins",
}
- pluginListCmd = &cobra.Command{
+ listCmd := &cobra.Command{
Use: "list",
Short: "List installed plugins",
- Run: func(cmd *cobra.Command, args []string) {
- runPluginList(cmd.Context())
- },
+ Long: "List all installed plugins with their metadata",
+ Run: pluginList,
}
- pluginEnableCmd = &cobra.Command{
- Use: "enable ",
- Short: "Enable a plugin",
+ infoCmd := &cobra.Command{
+ Use: "info [pluginPackage|pluginName]",
+ Short: "Show details of a plugin",
+ Long: "Show detailed information about a plugin package (.ndp file) or an installed 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)
- }
- },
+ Run: pluginInfo,
}
- pluginDisableCmd = &cobra.Command{
- Use: "disable ",
- Short: "Disable a plugin",
+ installCmd := &cobra.Command{
+ Use: "install [pluginPackage]",
+ Short: "Install a plugin from a .ndp file",
+ Long: "Install a Navidrome Plugin Package (.ndp) file",
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)
- }
- },
+ Run: pluginInstall,
}
-)
-var (
- pluginInfoCmd = &cobra.Command{
- Use: "info ",
- Short: "Show details for an installed plugin or a .ndp package",
+ removeCmd := &cobra.Command{
+ Use: "remove [pluginName]",
+ Short: "Remove an installed plugin",
+ Long: "Remove a plugin by name",
Args: cobra.ExactArgs(1),
- Run: func(cmd *cobra.Command, args []string) {
- runPluginInfo(cmd.Context(), args[0])
- },
+ Run: pluginRemove,
}
- pluginValidateCmd = &cobra.Command{
- Use: "validate ",
- Short: "Validate an installed plugin or a .ndp package manifest",
+ updateCmd := &cobra.Command{
+ Use: "update [pluginPackage]",
+ Short: "Update an existing plugin",
+ Long: "Update an installed plugin with a new version from a .ndp file",
Args: cobra.ExactArgs(1),
- Run: func(cmd *cobra.Command, args []string) {
- runPluginValidate(cmd.Context(), args[0])
- },
+ Run: pluginUpdate,
}
-)
-// 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)
+ refreshCmd := &cobra.Command{
+ Use: "refresh [pluginName]",
+ Short: "Reload a plugin without restarting Navidrome",
+ Long: "Reload and recompile a plugin without needing to restart Navidrome",
+ Args: cobra.ExactArgs(1),
+ Run: pluginRefresh,
+ }
+
+ devCmd := &cobra.Command{
+ Use: "dev [folder_path]",
+ Short: "Create symlink to development folder",
+ Long: "Create a symlink from a plugin development folder to the plugins directory for easier development",
+ Args: cobra.ExactArgs(1),
+ Run: pluginDev,
+ }
+
+ pluginCmd.AddCommand(listCmd, infoCmd, installCmd, removeCmd, updateCmd, refreshCmd, devCmd)
+ rootCmd.AddCommand(pluginCmd)
}
-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
-}
+// Validation helpers
-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)
+func validatePluginPackageFile(path string) error {
+ if !utils.FileExists(path) {
+ return fmt.Errorf("plugin package not found: %s", path)
}
- 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
- }
+ if filepath.Ext(path) != pluginPackageExtension {
+ return fmt.Errorf("not a valid plugin package: %s (expected %s extension)", path, pluginPackageExtension)
}
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
+func validatePluginDirectory(pluginsDir, pluginName string) (string, error) {
+ pluginDir := filepath.Join(pluginsDir, pluginName)
+ if !utils.FileExists(pluginDir) {
+ return "", fmt.Errorf("plugin not found: %s (path: %s)", pluginName, pluginDir)
}
- 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
+ return pluginDir, 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
+func resolvePluginPath(pluginDir string) (resolvedPath string, isSymlink bool, err error) {
+ // Check if it's a directory or a symlink
+ lstat, err := os.Lstat(pluginDir)
+ if err != nil {
+ return "", false, fmt.Errorf("failed to stat plugin: %w", err)
}
- if strings.HasPrefix(strings.TrimSpace(value), "[") {
- if !json.Valid([]byte(value)) {
- return "", fmt.Errorf("invalid JSON in --libraries")
+
+ isSymlink = lstat.Mode()&os.ModeSymlink != 0
+
+ if isSymlink {
+ // Resolve the symlink target
+ targetDir, err := os.Readlink(pluginDir)
+ if err != nil {
+ return "", true, fmt.Errorf("failed to resolve symlink: %w", err)
}
- return value, nil
+
+ // If target is a relative path, make it absolute
+ if !filepath.IsAbs(targetDir) {
+ targetDir = filepath.Join(filepath.Dir(pluginDir), targetDir)
+ }
+
+ // Verify the target exists and is a directory
+ targetInfo, err := os.Stat(targetDir)
+ if err != nil {
+ return "", true, fmt.Errorf("failed to access symlink target %s: %w", targetDir, err)
+ }
+
+ if !targetInfo.IsDir() {
+ return "", true, fmt.Errorf("symlink target is not a directory: %s", targetDir)
+ }
+
+ return targetDir, true, nil
+ } else if !lstat.IsDir() {
+ return "", false, fmt.Errorf("not a valid plugin directory: %s", pluginDir)
}
- 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)
+
+ return pluginDir, false, nil
+}
+
+// Package handling helpers
+
+func loadAndValidatePackage(ndpPath string) (*plugins.PluginPackage, error) {
+ if err := validatePluginPackageFile(ndpPath); err != nil {
+ return nil, err
+ }
+
+ pkg, err := plugins.LoadPackage(ndpPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load plugin package: %w", err)
+ }
+
+ return pkg, nil
+}
+
+func extractAndSetupPlugin(ndpPath, targetDir string) error {
+ if err := plugins.ExtractPackage(ndpPath, targetDir); err != nil {
+ return fmt.Errorf("failed to extract plugin package: %w", err)
+ }
+
+ ensurePluginDirPermissions(targetDir)
+ return nil
+}
+
+// Display helpers
+
+func displayPluginTableRow(w *tabwriter.Writer, discovery plugins.PluginDiscoveryEntry) {
+ if discovery.Error != nil {
+ // Handle global errors (like directory read failure)
+ if discovery.ID == "" {
+ log.Error("Failed to read plugins directory", "folder", conf.Server.Plugins.Folder, discovery.Error)
+ return
+ }
+ // Handle individual plugin errors - show them in the table
+ fmt.Fprintf(w, "%s\tERROR\tERROR\tERROR\tERROR\t%v\n", discovery.ID, discovery.Error)
+ return
+ }
+
+ // Mark symlinks with an indicator
+ nameDisplay := discovery.Manifest.Name
+ if discovery.IsSymlink {
+ nameDisplay = nameDisplay + " (dev)"
+ }
+
+ // Convert capabilities to strings
+ capabilities := slice.Map(discovery.Manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string {
+ return string(cap)
+ })
+
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
+ discovery.ID,
+ nameDisplay,
+ cmp.Or(discovery.Manifest.Author, "-"),
+ cmp.Or(discovery.Manifest.Version, "-"),
+ strings.Join(capabilities, ", "),
+ cmp.Or(discovery.Manifest.Description, "-"))
+}
+
+func displayTypedPermissions(permissions schema.PluginManifestPermissions, indent string) {
+ if permissions.Http != nil {
+ fmt.Printf("%shttp:\n", indent)
+ fmt.Printf("%s Reason: %s\n", indent, permissions.Http.Reason)
+ fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Http.AllowLocalNetwork)
+ fmt.Printf("%s Allowed URLs:\n", indent)
+ for urlPattern, methodEnums := range permissions.Http.AllowedUrls {
+ methods := make([]string, len(methodEnums))
+ for i, methodEnum := range methodEnums {
+ methods[i] = string(methodEnum)
}
- ids = append(ids, id)
+ fmt.Printf("%s %s: [%s]\n", indent, urlPattern, strings.Join(methods, ", "))
+ }
+ fmt.Println()
+ }
+
+ if permissions.Config != nil {
+ fmt.Printf("%sconfig:\n", indent)
+ fmt.Printf("%s Reason: %s\n", indent, permissions.Config.Reason)
+ fmt.Println()
+ }
+
+ if permissions.Scheduler != nil {
+ fmt.Printf("%sscheduler:\n", indent)
+ fmt.Printf("%s Reason: %s\n", indent, permissions.Scheduler.Reason)
+ fmt.Println()
+ }
+
+ if permissions.Websocket != nil {
+ fmt.Printf("%swebsocket:\n", indent)
+ fmt.Printf("%s Reason: %s\n", indent, permissions.Websocket.Reason)
+ fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Websocket.AllowLocalNetwork)
+ fmt.Printf("%s Allowed URLs: [%s]\n", indent, strings.Join(permissions.Websocket.AllowedUrls, ", "))
+ fmt.Println()
+ }
+
+ if permissions.Cache != nil {
+ fmt.Printf("%scache:\n", indent)
+ fmt.Printf("%s Reason: %s\n", indent, permissions.Cache.Reason)
+ fmt.Println()
+ }
+
+ if permissions.Artwork != nil {
+ fmt.Printf("%sartwork:\n", indent)
+ fmt.Printf("%s Reason: %s\n", indent, permissions.Artwork.Reason)
+ fmt.Println()
+ }
+
+ if permissions.Subsonicapi != nil {
+ allowedUsers := "All Users"
+ if len(permissions.Subsonicapi.AllowedUsernames) > 0 {
+ allowedUsers = strings.Join(permissions.Subsonicapi.AllowedUsernames, ", ")
+ }
+ fmt.Printf("%ssubsonicapi:\n", indent)
+ fmt.Printf("%s Reason: %s\n", indent, permissions.Subsonicapi.Reason)
+ fmt.Printf("%s Allow Admins: %t\n", indent, permissions.Subsonicapi.AllowAdmins)
+ fmt.Printf("%s Allowed Usernames: [%s]\n", indent, allowedUsers)
+ fmt.Println()
+ }
+}
+
+func displayPluginDetails(manifest *schema.PluginManifest, fileInfo *pluginFileInfo, permInfo *pluginPermissionInfo) {
+ fmt.Println("\nPlugin Information:")
+ fmt.Printf(" Name: %s\n", manifest.Name)
+ fmt.Printf(" Author: %s\n", manifest.Author)
+ fmt.Printf(" Version: %s\n", manifest.Version)
+ fmt.Printf(" Description: %s\n", manifest.Description)
+
+ fmt.Print(" Capabilities: ")
+ capabilities := make([]string, len(manifest.Capabilities))
+ for i, cap := range manifest.Capabilities {
+ capabilities[i] = string(cap)
+ }
+ fmt.Print(strings.Join(capabilities, ", "))
+ fmt.Println()
+
+ // Display manifest permissions using the typed permissions
+ fmt.Println(" Required Permissions:")
+ displayTypedPermissions(manifest.Permissions, " ")
+
+ // Print file information if available
+ if fileInfo != nil {
+ fmt.Println("Package Information:")
+ fmt.Printf(" File: %s\n", fileInfo.path)
+ fmt.Printf(" Size: %d bytes (%.2f KB)\n", fileInfo.size, float64(fileInfo.size)/1024)
+ fmt.Printf(" SHA-256: %s\n", fileInfo.hash)
+ fmt.Printf(" Modified: %s\n", fileInfo.modTime.Format(time.RFC3339))
+ }
+
+ // Print file permissions information if available
+ if permInfo != nil {
+ fmt.Println("File Permissions:")
+ fmt.Printf(" Plugin Directory: %s (%s)\n", permInfo.dirPath, permInfo.dirMode)
+ if permInfo.isSymlink {
+ fmt.Printf(" Symlink Target: %s (%s)\n", permInfo.targetPath, permInfo.targetMode)
+ }
+ fmt.Printf(" Manifest File: %s\n", permInfo.manifestMode)
+ if permInfo.wasmMode != "" {
+ fmt.Printf(" WASM File: %s\n", permInfo.wasmMode)
}
}
- 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)
+type pluginFileInfo struct {
+ path string
+ size int64
+ hash string
+ modTime time.Time
+}
+
+type pluginPermissionInfo struct {
+ dirPath string
+ dirMode string
+ isSymlink bool
+ targetPath string
+ targetMode string
+ manifestMode string
+ wasmMode string
+}
+
+func getFileInfo(path string) *pluginFileInfo {
+ fileInfo, err := os.Stat(path)
+ if err != nil {
+ log.Error("Failed to get file information", err)
+ return nil
+ }
+
+ return &pluginFileInfo{
+ path: path,
+ size: fileInfo.Size(),
+ hash: calculateSHA256(path),
+ modTime: fileInfo.ModTime(),
+ }
+}
+
+func getPermissionInfo(pluginDir string) *pluginPermissionInfo {
+ // Get plugin directory permissions
+ dirInfo, err := os.Lstat(pluginDir)
+ if err != nil {
+ log.Error("Failed to get plugin directory permissions", err)
+ return nil
+ }
+
+ permInfo := &pluginPermissionInfo{
+ dirPath: pluginDir,
+ dirMode: dirInfo.Mode().String(),
+ }
+
+ // Check if it's a symlink
+ if dirInfo.Mode()&os.ModeSymlink != 0 {
+ permInfo.isSymlink = true
+
+ // Get target path and permissions
+ targetPath, err := os.Readlink(pluginDir)
+ if err == nil {
+ if !filepath.IsAbs(targetPath) {
+ targetPath = filepath.Join(filepath.Dir(pluginDir), targetPath)
+ }
+ permInfo.targetPath = targetPath
+
+ if targetInfo, err := os.Stat(targetPath); err == nil {
+ permInfo.targetMode = targetInfo.Mode().String()
+ }
}
- },
+ }
+
+ // Get manifest file permissions
+ manifestPath := filepath.Join(pluginDir, "manifest.json")
+ if manifestInfo, err := os.Stat(manifestPath); err == nil {
+ permInfo.manifestMode = manifestInfo.Mode().String()
+ }
+
+ // Get WASM file permissions (look for .wasm files)
+ entries, err := os.ReadDir(pluginDir)
+ if err == nil {
+ for _, entry := range entries {
+ if filepath.Ext(entry.Name()) == ".wasm" {
+ wasmPath := filepath.Join(pluginDir, entry.Name())
+ if wasmInfo, err := os.Stat(wasmPath); err == nil {
+ permInfo.wasmMode = wasmInfo.Mode().String()
+ break // Just show the first WASM file found
+ }
+ }
+ }
+ }
+
+ return permInfo
}
-func rescanPlugins(ctx context.Context, mgr pluginManager) error {
- return mgr.RescanPlugins(ctx)
+// Command implementations
+
+func pluginList(cmd *cobra.Command, args []string) {
+ discoveries := plugins.DiscoverPlugins(conf.Server.Plugins.Folder)
+
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(w, "ID\tNAME\tAUTHOR\tVERSION\tCAPABILITIES\tDESCRIPTION")
+
+ for _, discovery := range discoveries {
+ displayPluginTableRow(w, discovery)
+ }
+ w.Flush()
+}
+
+func pluginInfo(cmd *cobra.Command, args []string) {
+ path := args[0]
+ pluginsDir := conf.Server.Plugins.Folder
+
+ var manifest *schema.PluginManifest
+ var fileInfo *pluginFileInfo
+ var permInfo *pluginPermissionInfo
+
+ if filepath.Ext(path) == pluginPackageExtension {
+ // It's a package file
+ pkg, err := loadAndValidatePackage(path)
+ if err != nil {
+ log.Fatal("Failed to load plugin package", err)
+ }
+ manifest = pkg.Manifest
+ fileInfo = getFileInfo(path)
+ // No permission info for package files
+ } else {
+ // It's a plugin name
+ pluginDir, err := validatePluginDirectory(pluginsDir, path)
+ if err != nil {
+ log.Fatal("Plugin validation failed", err)
+ }
+
+ manifest, err = plugins.LoadManifest(pluginDir)
+ if err != nil {
+ log.Fatal("Failed to load plugin manifest", err)
+ }
+
+ // Get permission info for installed plugins
+ permInfo = getPermissionInfo(pluginDir)
+ }
+
+ displayPluginDetails(manifest, fileInfo, permInfo)
+}
+
+func pluginInstall(cmd *cobra.Command, args []string) {
+ ndpPath := args[0]
+ pluginsDir := conf.Server.Plugins.Folder
+
+ pkg, err := loadAndValidatePackage(ndpPath)
+ if err != nil {
+ log.Fatal("Package validation failed", err)
+ }
+
+ // Create target directory based on plugin name
+ targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
+
+ // Check if plugin already exists
+ if utils.FileExists(targetDir) {
+ log.Fatal("Plugin already installed", "name", pkg.Manifest.Name, "path", targetDir,
+ "use", "navidrome plugin update")
+ }
+
+ if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
+ log.Fatal("Plugin installation failed", err)
+ }
+
+ fmt.Printf("Plugin '%s' v%s installed successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
+}
+
+func pluginRemove(cmd *cobra.Command, args []string) {
+ pluginName := args[0]
+ pluginsDir := conf.Server.Plugins.Folder
+
+ pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
+ if err != nil {
+ log.Fatal("Plugin validation failed", err)
+ }
+
+ _, isSymlink, err := resolvePluginPath(pluginDir)
+ if err != nil {
+ log.Fatal("Failed to resolve plugin path", err)
+ }
+
+ if isSymlink {
+ // For symlinked plugins (dev mode), just remove the symlink
+ if err := os.Remove(pluginDir); err != nil {
+ log.Fatal("Failed to remove plugin symlink", "name", pluginName, err)
+ }
+ fmt.Printf("Development plugin symlink '%s' removed successfully (target directory preserved)\n", pluginName)
+ } else {
+ // For regular plugins, remove the entire directory
+ if err := os.RemoveAll(pluginDir); err != nil {
+ log.Fatal("Failed to remove plugin directory", "name", pluginName, err)
+ }
+ fmt.Printf("Plugin '%s' removed successfully\n", pluginName)
+ }
+}
+
+func pluginUpdate(cmd *cobra.Command, args []string) {
+ ndpPath := args[0]
+ pluginsDir := conf.Server.Plugins.Folder
+
+ pkg, err := loadAndValidatePackage(ndpPath)
+ if err != nil {
+ log.Fatal("Package validation failed", err)
+ }
+
+ // Check if plugin exists
+ targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
+ if !utils.FileExists(targetDir) {
+ log.Fatal("Plugin not found", "name", pkg.Manifest.Name, "path", targetDir,
+ "use", "navidrome plugin install")
+ }
+
+ // Create a backup of the existing plugin
+ backupDir := targetDir + ".bak." + time.Now().Format("20060102150405")
+ if err := os.Rename(targetDir, backupDir); err != nil {
+ log.Fatal("Failed to backup existing plugin", err)
+ }
+
+ // Extract the new package
+ if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
+ // Restore backup if extraction failed
+ os.RemoveAll(targetDir)
+ _ = os.Rename(backupDir, targetDir) // Ignore error as we're already in a fatal path
+ log.Fatal("Plugin update failed", err)
+ }
+
+ // Remove the backup
+ os.RemoveAll(backupDir)
+
+ fmt.Printf("Plugin '%s' updated to v%s successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
+}
+
+func pluginRefresh(cmd *cobra.Command, args []string) {
+ pluginName := args[0]
+ pluginsDir := conf.Server.Plugins.Folder
+
+ pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
+ if err != nil {
+ log.Fatal("Plugin validation failed", err)
+ }
+
+ resolvedPath, isSymlink, err := resolvePluginPath(pluginDir)
+ if err != nil {
+ log.Fatal("Failed to resolve plugin path", err)
+ }
+
+ if isSymlink {
+ log.Debug("Processing symlinked plugin", "name", pluginName, "link", pluginDir, "target", resolvedPath)
+ }
+
+ fmt.Printf("Refreshing plugin '%s'...\n", pluginName)
+
+ // Get the plugin manager and refresh
+ mgr := GetPluginManager(cmd.Context())
+ log.Debug("Scanning plugins directory", "path", pluginsDir)
+ mgr.ScanPlugins()
+
+ log.Info("Waiting for plugin compilation to complete", "name", pluginName)
+
+ // Wait for compilation to complete
+ if err := mgr.EnsureCompiled(pluginName); err != nil {
+ log.Fatal("Failed to compile refreshed plugin", "name", pluginName, err)
+ }
+
+ log.Info("Plugin compilation completed successfully", "name", pluginName)
+ fmt.Printf("Plugin '%s' refreshed successfully\n", pluginName)
+}
+
+func pluginDev(cmd *cobra.Command, args []string) {
+ sourcePath, err := filepath.Abs(args[0])
+ if err != nil {
+ log.Fatal("Invalid path", "path", args[0], err)
+ }
+ pluginsDir := conf.Server.Plugins.Folder
+
+ // Validate source directory and manifest
+ if err := validateDevSource(sourcePath); err != nil {
+ log.Fatal("Source validation failed", err)
+ }
+
+ // Load manifest to get plugin name
+ manifest, err := plugins.LoadManifest(sourcePath)
+ if err != nil {
+ log.Fatal("Failed to load plugin manifest", "path", filepath.Join(sourcePath, "manifest.json"), err)
+ }
+
+ pluginName := cmp.Or(manifest.Name, filepath.Base(sourcePath))
+ targetPath := filepath.Join(pluginsDir, pluginName)
+
+ // Handle existing target
+ if err := handleExistingTarget(targetPath, sourcePath); err != nil {
+ log.Fatal("Failed to handle existing target", err)
+ }
+
+ // Create target directory if needed
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ log.Fatal("Failed to create plugins directory", "path", filepath.Dir(targetPath), err)
+ }
+
+ // Create the symlink
+ if err := os.Symlink(sourcePath, targetPath); err != nil {
+ log.Fatal("Failed to create symlink", "source", sourcePath, "target", targetPath, err)
+ }
+
+ fmt.Printf("Development symlink created: '%s' -> '%s'\n", targetPath, sourcePath)
+ fmt.Println("Plugin can be refreshed with: navidrome plugin refresh", pluginName)
+}
+
+// Utility functions
+
+func validateDevSource(sourcePath string) error {
+ sourceInfo, err := os.Stat(sourcePath)
+ if err != nil {
+ return fmt.Errorf("source folder not found: %s (%w)", sourcePath, err)
+ }
+ if !sourceInfo.IsDir() {
+ return fmt.Errorf("source path is not a directory: %s", sourcePath)
+ }
+
+ manifestPath := filepath.Join(sourcePath, "manifest.json")
+ if !utils.FileExists(manifestPath) {
+ return fmt.Errorf("source folder missing manifest.json: %s", sourcePath)
+ }
+
+ return nil
+}
+
+func handleExistingTarget(targetPath, sourcePath string) error {
+ if !utils.FileExists(targetPath) {
+ return nil // Nothing to handle
+ }
+
+ // Check if it's already a symlink to our source
+ existingLink, err := os.Readlink(targetPath)
+ if err == nil && existingLink == sourcePath {
+ fmt.Printf("Symlink already exists and points to the correct source\n")
+ return fmt.Errorf("symlink already exists") // This will cause early return in caller
+ }
+
+ // Handle case where target exists but is not a symlink to our source
+ fmt.Printf("Target path '%s' already exists.\n", targetPath)
+ fmt.Print("Do you want to replace it? (y/N): ")
+ var response string
+ _, err = fmt.Scanln(&response)
+ if err != nil || strings.ToLower(response) != "y" {
+ if err != nil {
+ log.Debug("Error reading input, assuming 'no'", err)
+ }
+ return fmt.Errorf("operation canceled")
+ }
+
+ // Remove existing target
+ if err := os.RemoveAll(targetPath); err != nil {
+ return fmt.Errorf("failed to remove existing target %s: %w", targetPath, err)
+ }
+
+ return nil
+}
+
+func ensurePluginDirPermissions(dir string) {
+ if err := os.Chmod(dir, pluginDirPermissions); err != nil {
+ log.Error("Failed to set plugin directory permissions", "dir", dir, err)
+ }
+
+ // Apply permissions to all files in the directory
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ log.Error("Failed to read plugin directory", "dir", dir, err)
+ return
+ }
+
+ for _, entry := range entries {
+ path := filepath.Join(dir, entry.Name())
+ info, err := os.Stat(path)
+ if err != nil {
+ log.Error("Failed to stat file", "path", path, err)
+ continue
+ }
+
+ mode := os.FileMode(pluginFilePermissions) // Files
+ if info.IsDir() {
+ mode = os.FileMode(pluginDirPermissions) // Directories
+ ensurePluginDirPermissions(path) // Recursive
+ }
+
+ if err := os.Chmod(path, mode); err != nil {
+ log.Error("Failed to set file permissions", "path", path, err)
+ }
+ }
+}
+
+func calculateSHA256(filePath string) string {
+ file, err := os.Open(filePath)
+ if err != nil {
+ log.Error("Failed to open file for hashing", err)
+ return "N/A"
+ }
+ defer file.Close()
+
+ hasher := sha256.New()
+ if _, err := io.Copy(hasher, file); err != nil {
+ log.Error("Failed to calculate hash", err)
+ return "N/A"
+ }
+
+ return hex.EncodeToString(hasher.Sum(nil))
}
diff --git a/cmd/plugin_test.go b/cmd/plugin_test.go
index b6233057f..3a4aefa88 100644
--- a/cmd/plugin_test.go
+++ b/cmd/plugin_test.go
@@ -1,360 +1,193 @@
package cmd
import (
- "context"
- "encoding/json"
- "errors"
+ "io"
+ "os"
+ "path/filepath"
"strings"
- "time"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/plugins"
- "github.com/navidrome/navidrome/tests"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "github.com/spf13/cobra"
)
-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 CLI Commands", func() {
+ var tempDir string
+ var cmd *cobra.Command
+ var stdOut *os.File
+ var origStdout *os.File
+ var outReader *os.File
-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"))
- })
+ // Helper to create a test plugin with the given name and details
+ createTestPlugin := func(name, author, version string, capabilities []string) string {
+ pluginDir := filepath.Join(tempDir, name)
+ Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
- It("defaults `info -f` to text", func() {
- Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text"))
- })
-})
+ // Create a properly formatted capabilities JSON array
+ capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"`
-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"))
- })
+ manifest := `{
+ "name": "` + name + `",
+ "author": "` + author + `",
+ "version": "` + version + `",
+ "description": "Plugin for testing",
+ "website": "https://test.navidrome.org/` + name + `",
+ "capabilities": [` + capabilitiesJSON + `],
+ "permissions": {}
+ }`
- 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))
- })
+ Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
- 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"))
- })
+ // Create a dummy WASM file
+ wasmContent := []byte("dummy wasm content for testing")
+ Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
- It("errors on an unknown format", func() {
- _, err := formatPluginList(samplePlugins, "yaml")
- Expect(err).To(HaveOccurred())
- })
-})
+ return pluginDir
+ }
-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
+ // Helper to execute a command and return captured output
+ captureOutput := func(reader io.Reader) string {
+ stdOut.Close()
+ outputBytes, err := io.ReadAll(reader)
+ Expect(err).NotTo(HaveOccurred())
+ return string(outputBytes)
+ }
BeforeEach(func() {
- fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
+ DeferCleanup(configtest.SetupConfig())
+ tempDir = GinkgoT().TempDir()
+
+ // Setup config
+ conf.Server.Plugins.Enabled = true
+ conf.Server.Plugins.Folder = tempDir
+
+ // Create a command for testing
+ cmd = &cobra.Command{Use: "test"}
+
+ // Setup stdout capture
+ origStdout = os.Stdout
+ var err error
+ outReader, stdOut, err = os.Pipe()
+ Expect(err).NotTo(HaveOccurred())
+ os.Stdout = stdOut
+
+ DeferCleanup(func() {
+ os.Stdout = origStdout
+ })
})
- 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),
+ AfterEach(func() {
+ os.Stdout = origStdout
+ if stdOut != nil {
+ stdOut.Close()
}
- 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,
+ if outReader != nil {
+ outReader.Close()
}
- 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))
+ Describe("Plugin list command", func() {
+ It("should list installed plugins", func() {
+ // Create test plugins
+ createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"})
+ createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"})
+
+ // Execute command
+ pluginList(cmd, []string{})
+
+ // Verify output
+ output := captureOutput(outReader)
+
+ Expect(output).To(ContainSubstring("plugin1"))
+ Expect(output).To(ContainSubstring("Test Author"))
+ Expect(output).To(ContainSubstring("1.0.0"))
+ Expect(output).To(ContainSubstring("MetadataAgent"))
+
+ Expect(output).To(ContainSubstring("plugin2"))
+ Expect(output).To(ContainSubstring("Another Author"))
+ Expect(output).To(ContainSubstring("2.1.0"))
+ Expect(output).To(ContainSubstring("Scrobbler"))
+ })
+ })
+
+ Describe("Plugin info command", func() {
+ It("should display information about an installed plugin", func() {
+ // Create test plugin with multiple capabilities
+ createTestPlugin("test-plugin", "Test Author", "1.0.0",
+ []string{"MetadataAgent", "Scrobbler"})
+
+ // Execute command
+ pluginInfo(cmd, []string{"test-plugin"})
+
+ // Verify output
+ output := captureOutput(outReader)
+
+ Expect(output).To(ContainSubstring("Name: test-plugin"))
+ Expect(output).To(ContainSubstring("Author: Test Author"))
+ Expect(output).To(ContainSubstring("Version: 1.0.0"))
+ Expect(output).To(ContainSubstring("Description: Plugin for testing"))
+ Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler"))
+ })
+ })
+
+ Describe("Plugin remove command", func() {
+ It("should remove a regular plugin directory", func() {
+ // Create test plugin
+ pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0",
+ []string{"MetadataAgent"})
+
+ // Execute command
+ pluginRemove(cmd, []string{"regular-plugin"})
+
+ // Verify output
+ output := captureOutput(outReader)
+ Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully"))
+
+ // Verify directory is actually removed
+ _, err := os.Stat(pluginDir)
+ Expect(os.IsNotExist(err)).To(BeTrue())
+ })
+
+ It("should remove only the symlink for a development plugin", func() {
+ // Create a real source directory
+ sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source")
+ Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed())
+
+ manifest := `{
+ "name": "dev-plugin",
+ "author": "Dev Author",
+ "version": "0.1.0",
+ "description": "Development plugin for testing",
+ "website": "https://test.navidrome.org/dev-plugin",
+ "capabilities": ["Scrobbler"],
+ "permissions": {}
+ }`
+ Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
+
+ // Create a dummy WASM file
+ wasmContent := []byte("dummy wasm content for testing")
+ Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
+
+ // Create a symlink in the plugins directory
+ symlinkPath := filepath.Join(tempDir, "dev-plugin")
+ Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed())
+
+ // Execute command
+ pluginRemove(cmd, []string{"dev-plugin"})
+
+ // Verify output
+ output := captureOutput(outReader)
+ Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully"))
+ Expect(output).To(ContainSubstring("target directory preserved"))
+
+ // Verify the symlink is removed but source directory exists
+ _, err := os.Lstat(symlinkPath)
+ Expect(os.IsNotExist(err)).To(BeTrue())
+
+ _, err = os.Stat(sourceDir)
+ Expect(err).NotTo(HaveOccurred())
+ })
})
})
diff --git a/cmd/root.go b/cmd/root.go
index e39c55365..9618b16e6 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -2,7 +2,6 @@ package cmd
import (
"context"
- "net/http"
"os"
"os/signal"
"strings"
@@ -10,9 +9,9 @@ import (
"time"
"github.com/go-chi/chi/v5/middleware"
+ _ "github.com/navidrome/navidrome/adapters/taglib"
"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"
@@ -23,12 +22,6 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/sync/errgroup"
-
- // Import adapters to register them
- _ "github.com/navidrome/navidrome/adapters/deezer"
- _ "github.com/navidrome/navidrome/adapters/gotaglib"
- _ "github.com/navidrome/navidrome/adapters/lastfm"
- _ "github.com/navidrome/navidrome/adapters/listenbrainz"
)
var (
@@ -44,9 +37,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 +69,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 +91,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 +119,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 +126,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 +135,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 +161,45 @@ 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 := conf.Server.Scanner.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 +269,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 +313,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,77 +327,26 @@ 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 {
- manager := GetPluginManager(ctx)
if !conf.Server.Plugins.Enabled {
- log.Debug("Plugin system is DISABLED")
+ log.Debug("Plugins are DISABLED")
return nil
}
log.Info(ctx, "Starting plugin manager")
- return manager.Start(ctx)
+ // Get the manager instance and scan for plugins
+ manager := GetPluginManager(ctx)
+ manager.ScanPlugins()
+
+ return nil
}
}
// TODO: Implement some struct tags to map flags to viper
func init() {
cobra.OnInitialize(func() {
- conf.InitConfig(cfgFile, true)
+ conf.InitConfig(cfgFile)
})
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
@@ -479,13 +374,12 @@ func init() {
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
- rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
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"))
@@ -503,7 +397,6 @@ func init() {
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
- _ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
}
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..d37ccd69f 100644
--- a/cmd/scan.go
+++ b/cmd/scan.go
@@ -1,19 +1,13 @@
package cmd
import (
- "bufio"
"context"
"encoding/gob"
- "errors"
- "fmt"
"os"
- "strings"
- "github.com/navidrome/navidrome/core/artwork"
- "github.com/navidrome/navidrome/core/playlists"
+ "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/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/utils/pl"
@@ -23,15 +17,11 @@ import (
var (
fullScan bool
subprocess bool
- targets []string
- targetFile string
)
func init() {
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
- scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
- scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
rootCmd.AddCommand(scanCmd)
}
@@ -44,20 +34,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 +50,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,37 +66,9 @@ func runScanner(ctx context.Context) {
sqlDB := db.Db()
defer db.Db().Close()
ds := persistence.New(sqlDB)
- pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
+ pls := core.NewPlaylists(ds)
- // Parse targets from command line or file
- var scanTargets []model.ScanTarget
- var err error
-
- if targetFile != "" {
- scanTargets, err = readTargetsFromFile(targetFile)
- if err != nil {
- log.Fatal(ctx, "Failed to read targets from file", err)
- }
- log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
- } else if len(targets) > 0 {
- scanTargets, err = model.ParseTargets(targets)
- if err != nil {
- log.Fatal(ctx, "Failed to parse targets", err)
- }
- 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)
+ progress, err := scanner.CallScan(ctx, ds, pls, fullScan)
if err != nil {
log.Fatal(ctx, "Failed to scan", err)
}
@@ -121,48 +77,6 @@ func runScanner(ctx context.Context) {
if subprocess {
trackScanAsSubprocess(ctx, progress)
} else {
- changesDetected, scanErr := trackScanInteractively(ctx, progress)
- runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr)
+ trackScanInteractively(ctx, progress)
}
}
-
-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)
- }
- }
-}
-
-// readTargetsFromFile reads scan targets from a file, one per line.
-// Each line should be in the format "libraryID:folderPath".
-// Empty lines and lines starting with # are ignored.
-func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
- file, err := os.Open(filePath)
- if err != nil {
- return nil, fmt.Errorf("failed to open target file: %w", err)
- }
- defer file.Close()
-
- var targetStrings []string
- scanner := bufio.NewScanner(file)
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- // Skip empty lines and comments
- if line == "" {
- continue
- }
- targetStrings = append(targetStrings, line)
- }
-
- if err := scanner.Err(); err != nil {
- return nil, fmt.Errorf("failed to read target file: %w", err)
- }
-
- return model.ParseTargets(targetStrings)
-}
diff --git a/cmd/scan_test.go b/cmd/scan_test.go
deleted file mode 100644
index 309d09f98..000000000
--- a/cmd/scan_test.go
+++ /dev/null
@@ -1,104 +0,0 @@
-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
-
- BeforeEach(func() {
- var err error
- tempDir, err = os.MkdirTemp("", "navidrome-test-")
- Expect(err).ToNot(HaveOccurred())
- })
-
- AfterEach(func() {
- os.RemoveAll(tempDir)
- })
-
- It("reads valid targets from file", func() {
- filePath := filepath.Join(tempDir, "targets.txt")
- content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n"
- err := os.WriteFile(filePath, []byte(content), 0600)
- Expect(err).ToNot(HaveOccurred())
-
- targets, err := readTargetsFromFile(filePath)
- Expect(err).ToNot(HaveOccurred())
- Expect(targets).To(HaveLen(3))
- Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"}))
- Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"}))
- Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"}))
- })
-
- It("skips empty lines", func() {
- filePath := filepath.Join(tempDir, "targets.txt")
- content := "1:Music/Rock\n\n2:Music/Jazz\n\n"
- err := os.WriteFile(filePath, []byte(content), 0600)
- Expect(err).ToNot(HaveOccurred())
-
- targets, err := readTargetsFromFile(filePath)
- Expect(err).ToNot(HaveOccurred())
- Expect(targets).To(HaveLen(2))
- })
-
- It("trims whitespace", func() {
- filePath := filepath.Join(tempDir, "targets.txt")
- content := " 1:Music/Rock \n\t2:Music/Jazz\t\n"
- err := os.WriteFile(filePath, []byte(content), 0600)
- Expect(err).ToNot(HaveOccurred())
-
- targets, err := readTargetsFromFile(filePath)
- Expect(err).ToNot(HaveOccurred())
- Expect(targets).To(HaveLen(2))
- Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
- Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
- })
-
- It("returns error for non-existent file", func() {
- _, err := readTargetsFromFile("/nonexistent/file.txt")
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("failed to open target file"))
- })
-
- It("returns error for invalid target format", func() {
- filePath := filepath.Join(tempDir, "targets.txt")
- content := "invalid-format\n"
- err := os.WriteFile(filePath, []byte(content), 0600)
- Expect(err).ToNot(HaveOccurred())
-
- _, err = readTargetsFromFile(filePath)
- Expect(err).To(HaveOccurred())
- })
-
- It("handles mixed valid and empty lines", func() {
- filePath := filepath.Join(tempDir, "targets.txt")
- content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n"
- err := os.WriteFile(filePath, []byte(content), 0600)
- Expect(err).ToNot(HaveOccurred())
-
- targets, err := readTargetsFromFile(filePath)
- Expect(err).ToNot(HaveOccurred())
- Expect(targets).To(HaveLen(2))
- })
-})
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..e277bd459 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,22 +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}}
-Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
+EnvironmentFile=-/etc/sysconfig/{{.Name}}
DevicePolicy=closed
NoNewPrivileges=yes
@@ -264,7 +259,7 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
-{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}}
+{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}}
ProtectSystem=full
[Install]
diff --git a/cmd/svc_test.go b/cmd/svc_test.go
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
deleted file mode 100644
index eb64e69fe..000000000
--- a/cmd/user.go
+++ /dev/null
@@ -1,475 +0,0 @@
-package cmd
-
-import (
- "context"
- "encoding/csv"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "strconv"
- "strings"
- "syscall"
- "time"
-
- "github.com/Masterminds/squirrel"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
- "github.com/spf13/cobra"
- "golang.org/x/term"
-)
-
-var (
- email string
- libraryIds []int
- name string
-
- removeEmail bool
- removeName bool
- setAdmin bool
- setPassword bool
- setRegularUser bool
-)
-
-func init() {
- rootCmd.AddCommand(userRoot)
-
- userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username")
-
- userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
- userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries")
-
- userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library")
- userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)")
-
- _ = userCreateCommand.MarkFlagRequired("username")
-
- userRoot.AddCommand(userCreateCommand)
-
- userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
- _ = userDeleteCommand.MarkFlagRequired("user")
- userRoot.AddCommand(userDeleteCommand)
-
- userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
-
- userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin")
- userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin")
- userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular")
-
- userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email")
- userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
- userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email")
-
- userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name")
- userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)")
- userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name")
-
- userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI")
-
- userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id")
-
- _ = userEditCommand.MarkFlagRequired("user")
- userRoot.AddCommand(userEditCommand)
-
- userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
- userRoot.AddCommand(userListCommand)
-}
-
-var (
- userRoot = &cobra.Command{
- Use: "user",
- Short: "Administer users",
- Long: "Create, delete, list, or update users",
- }
-
- userCreateCommand = &cobra.Command{
- Use: "create",
- Aliases: []string{"c"},
- Short: "Create a new user",
- Run: func(cmd *cobra.Command, args []string) {
- runCreateUser(cmd.Context())
- },
- }
-
- userDeleteCommand = &cobra.Command{
- Use: "delete",
- Aliases: []string{"d"},
- Short: "Deletes an existing user",
- Run: func(cmd *cobra.Command, args []string) {
- runDeleteUser(cmd.Context())
- },
- }
-
- userEditCommand = &cobra.Command{
- Use: "edit",
- Aliases: []string{"e"},
- Short: "Edit a user",
- Long: "Edit the password, admin status, and/or library access",
- Run: func(cmd *cobra.Command, args []string) {
- runUserEdit(cmd.Context())
- },
- }
-
- userListCommand = &cobra.Command{
- Use: "list",
- Short: "List users",
- Run: func(cmd *cobra.Command, args []string) {
- runUserList(cmd.Context())
- },
- }
-)
-
-func promptPassword() string {
- for {
- fmt.Print("Enter new password (press enter with no password to cancel): ")
- // This cast is necessary for some platforms
- password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
-
- if err != nil {
- log.Fatal("Error getting password", err)
- }
-
- fmt.Print("\nConfirm new password (press enter with no password to cancel): ")
- confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
-
- if err != nil {
- log.Fatal("Error getting password confirmation", err)
- }
-
- // clear the line.
- fmt.Println()
-
- pass := string(password)
- confirm := string(confirmation)
-
- if pass == "" {
- return ""
- }
-
- if pass == confirm {
- return pass
- }
-
- fmt.Println("Password and password confirmation do not match")
- }
-}
-
-func libraryError(libraries model.Libraries) error {
- ids := make([]int, len(libraries))
- for idx, library := range libraries {
- ids[idx] = library.ID
- }
- return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids)
-}
-
-func runCreateUser(ctx context.Context) {
- password := promptPassword()
- if password == "" {
- log.Fatal("Empty password provided, user creation cancelled")
- }
-
- user := model.User{
- UserName: userID,
- Email: email,
- Name: name,
- IsAdmin: setAdmin,
- NewPassword: password,
- }
-
- if user.Name == "" {
- user.Name = userID
- }
-
- ds, ctx := getAdminContext(ctx)
-
- err := ds.WithTx(func(tx model.DataStore) error {
- existingUser, err := tx.User().FindByUsername(ctx, userID)
- if existingUser != nil {
- return fmt.Errorf("existing user '%s'", userID)
- }
-
- if err != nil && !errors.Is(err, model.ErrNotFound) {
- return fmt.Errorf("failed to check existing username: %w", err)
- }
-
- if len(libraryIds) > 0 && !setAdmin {
- user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
- if err != nil {
- return err
- }
-
- if len(user.Libraries) != len(libraryIds) {
- return libraryError(user.Libraries)
- }
- } else {
- user.Libraries, err = tx.Library().GetAll(ctx)
- if err != nil {
- return err
- }
- }
-
- err = tx.User().Put(ctx, &user)
- if err != nil {
- return err
- }
-
- updatedIds := make([]int, len(user.Libraries))
- for idx, lib := range user.Libraries {
- updatedIds[idx] = lib.ID
- }
-
- err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
- return err
- })
-
- if err != nil {
- log.Fatal(ctx, err)
- }
-
- log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName)
-}
-
-func runDeleteUser(ctx context.Context) {
- ds, ctx := getAdminContext(ctx)
-
- var err error
- var user *model.User
-
- err = ds.WithTx(func(tx model.DataStore) error {
- count, err := tx.User().CountAll(ctx)
- if err != nil {
- return err
- }
-
- if count == 1 {
- return errors.New("refusing to delete the last user")
- }
-
- user, err = getUser(ctx, userID, tx)
- if err != nil {
- return err
- }
-
- return tx.User().Delete(ctx, user.ID)
- })
-
- if err != nil {
- log.Fatal(ctx, "Failed to delete user", err)
- }
-
- log.Info(ctx, "Deleted user", "username", user.UserName)
-}
-
-func runUserEdit(ctx context.Context) {
- ds, ctx := getAdminContext(ctx)
-
- var err error
- var user *model.User
- changes := []string{}
-
- err = ds.WithTx(func(tx model.DataStore) error {
- var newLibraries model.Libraries
-
- user, err = getUser(ctx, userID, tx)
- if err != nil {
- return err
- }
-
- if len(libraryIds) > 0 && !setAdmin {
- libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
-
- if err != nil {
- return err
- }
-
- if len(libraries) != len(libraryIds) {
- return libraryError(libraries)
- }
-
- newLibraries = libraries
- changes = append(changes, "updated library ids")
- }
-
- if setAdmin && !user.IsAdmin {
- libraries, err := tx.Library().GetAll(ctx)
- if err != nil {
- return err
- }
-
- user.IsAdmin = true
- user.Libraries = libraries
- changes = append(changes, "set admin")
-
- newLibraries = libraries
- }
-
- if setRegularUser && user.IsAdmin {
- user.IsAdmin = false
- changes = append(changes, "set regular user")
- }
-
- if setPassword {
- password := promptPassword()
-
- if password != "" {
- user.NewPassword = password
- changes = append(changes, "updated password")
- }
- }
-
- if email != "" && email != user.Email {
- user.Email = email
- changes = append(changes, "updated email")
- } else if removeEmail && user.Email != "" {
- user.Email = ""
- changes = append(changes, "removed email")
- }
-
- if name != "" && name != user.Name {
- user.Name = name
- changes = append(changes, "updated name")
- } else if removeName && user.Name != "" {
- user.Name = ""
- changes = append(changes, "removed name")
- }
-
- if len(changes) == 0 {
- return nil
- }
-
- err := tx.User().Put(ctx, user)
- if err != nil {
- return err
- }
-
- if len(newLibraries) > 0 {
- updatedIds := make([]int, len(newLibraries))
- for idx, lib := range newLibraries {
- updatedIds[idx] = lib.ID
- }
-
- err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
- if err != nil {
- return err
- }
- }
-
- return nil
- })
-
- if err != nil {
- log.Fatal(ctx, "Failed to update user", err)
- }
-
- if len(changes) == 0 {
- log.Info(ctx, "No changes for user", "user", user.UserName)
- } else {
- log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", "))
- }
-}
-
-type displayLibrary struct {
- ID int `json:"id"`
- Path string `json:"path"`
-}
-
-type displayUser struct {
- Id string `json:"id"`
- Username string `json:"username"`
- Name string `json:"name"`
- Email string `json:"email"`
- Admin bool `json:"admin"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
- LastAccess *time.Time `json:"lastAccess"`
- LastLogin *time.Time `json:"lastLogin"`
- Libraries []displayLibrary `json:"libraries"`
-}
-
-func runUserList(ctx context.Context) {
- if outputFormat != "csv" && outputFormat != "json" {
- log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
- }
-
- ds, ctx := getAdminContext(ctx)
-
- userList, err := ds.User().ReadAll(ctx)
- if err != nil {
- log.Fatal(ctx, "Failed to retrieve users", err)
- }
-
- if outputFormat == "csv" {
- w := csv.NewWriter(os.Stdout)
- _ = w.Write([]string{
- "user id",
- "username",
- "user's name",
- "user email",
- "admin",
- "created at",
- "updated at",
- "last access",
- "last login",
- "libraries",
- })
- for _, user := range userList {
- paths := make([]string, len(user.Libraries))
-
- for idx, library := range user.Libraries {
- paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path)
- }
-
- var lastAccess, lastLogin string
-
- if user.LastAccessAt != nil {
- lastAccess = user.LastAccessAt.Format(time.RFC3339Nano)
- } else {
- lastAccess = "never"
- }
-
- if user.LastLoginAt != nil {
- lastLogin = user.LastLoginAt.Format(time.RFC3339Nano)
- } else {
- lastLogin = "never"
- }
-
- _ = w.Write([]string{
- user.ID,
- user.UserName,
- user.Name,
- user.Email,
- strconv.FormatBool(user.IsAdmin),
- user.CreatedAt.Format(time.RFC3339Nano),
- user.UpdatedAt.Format(time.RFC3339Nano),
- lastAccess,
- lastLogin,
- fmt.Sprintf("'%s'", strings.Join(paths, "|")),
- })
- }
- w.Flush()
- } else {
- users := make([]displayUser, len(userList))
- for idx, user := range userList {
- paths := make([]displayLibrary, len(user.Libraries))
-
- for idx, library := range user.Libraries {
- paths[idx].ID = library.ID
- paths[idx].Path = library.Path
- }
-
- users[idx].Id = user.ID
- users[idx].Username = user.UserName
- users[idx].Name = user.Name
- users[idx].Email = user.Email
- users[idx].Admin = user.IsAdmin
- users[idx].CreatedAt = user.CreatedAt
- users[idx].UpdatedAt = user.UpdatedAt
- users[idx].LastAccess = user.LastAccessAt
- users[idx].LastLogin = user.LastLoginAt
- users[idx].Libraries = paths
- }
-
- j, _ := json.Marshal(users)
- fmt.Printf("%s\n", j)
- }
-}
diff --git a/cmd/utils.go b/cmd/utils.go
deleted file mode 100644
index f35a31fb1..000000000
--- a/cmd/utils.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package cmd
-
-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"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
- "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)
- ctx = auth.WithAdminUser(ctx, ds)
- u, _ := request.UserFrom(ctx)
- if !u.IsAdmin {
- log.Fatal(ctx, "There must be at least one admin user to run this command.")
- }
- return ds, ctx
-}
-
-func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
- user, err := ds.User().FindByUsername(ctx, 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)
- if err != nil {
- return nil, fmt.Errorf("finding user by id: %w", err)
- }
- }
-
- return user, nil
-}
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 2a396689d..bf13dc731 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -1,6 +1,6 @@
// Code generated by Wire. DO NOT EDIT.
-//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
+//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
//go:build !wireinject
// +build !wireinject
@@ -9,41 +9,30 @@ package cmd
import (
"context"
"github.com/google/wire"
- "github.com/navidrome/navidrome/adapters/lastfm"
- "github.com/navidrome/navidrome/adapters/listenbrainz"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/core/agents/lastfm"
+ "github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
- "github.com/navidrome/navidrome/core/lyrics"
- "github.com/navidrome/navidrome/core/matcher"
"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"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"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"
)
import (
- _ "github.com/navidrome/navidrome/adapters/deezer"
- _ "github.com/navidrome/navidrome/adapters/gotaglib"
- _ "github.com/navidrome/navidrome/adapters/lastfm"
- _ "github.com/navidrome/navidrome/adapters/listenbrainz"
+ _ "github.com/navidrome/navidrome/adapters/taglib"
)
// Injectors from wire_injectors.go:
@@ -58,7 +47,9 @@ func CreateServer() *server.Server {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
- insights := metrics.GetInstance(dataStore)
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, metricsMetrics)
+ insights := metrics.GetInstance(dataStore, manager)
serverServer := server.New(dataStore, broker, insights)
return serverServer
}
@@ -67,22 +58,22 @@ 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)
- insights := metrics.GetInstance(dataStore)
- broker := events.GetBroker()
+ playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
- modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
- watcher := scanner.GetWatcher(dataStore, modelScanner)
- manager := plugins.GetManager(dataStore, broker, metricsMetrics)
- library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
- user := core.NewUser(dataStore, manager)
- maintenance := core.NewMaintenance(dataStore)
+ manager := plugins.GetManager(dataStore, metricsMetrics)
+ insights := metrics.GetInstance(dataStore, manager)
+ fileCache := artwork.GetImageCache()
+ fFmpeg := ffmpeg.New()
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)
+ provider := external.NewProvider(dataStore, agentsAgents)
+ artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
+ cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
+ broker := events.GetBroker()
+ scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ watcher := scanner.GetWatcher(dataStore, scannerScanner)
+ library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
+ maintenance := core.NewMaintenance(dataStore)
+ router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
return router
}
@@ -90,63 +81,24 @@ 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)
+ manager := plugins.GetManager(dataStore, 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)
+ artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
+ transcodingCache := core.GetTranscodingCache()
+ mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
+ share := core.NewShare(dataStore)
+ archiver := core.NewArchiver(mediaStreamer, dataStore, share)
+ players := core.NewPlayers(dataStore)
+ cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
+ broker := events.GetBroker()
+ playlists := core.NewPlaylists(dataStore)
+ scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
- lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
- 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)
+ router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
}
@@ -154,15 +106,17 @@ 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)
- transcodingCache := stream.GetTranscodingCache()
- mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
- transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, metricsMetrics)
+ agentsAgents := agents.GetAgents(dataStore, manager)
+ provider := external.NewProvider(dataStore, agentsAgents)
+ artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
+ transcodingCache := core.GetTranscodingCache()
+ mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
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,17 +134,12 @@ 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)
- insights := metrics.GetInstance(dataStore)
+ metricsMetrics := metrics.GetPrometheusInstance(dataStore)
+ manager := plugins.GetManager(dataStore, metricsMetrics)
+ insights := metrics.GetInstance(dataStore, manager)
return insights
}
@@ -201,26 +150,38 @@ func CreatePrometheus() metrics.Metrics {
return metricsMetrics
}
-func CreateScanner(ctx context.Context) model.Scanner {
+func CreateScanner(ctx context.Context) scanner.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
- broker := events.GetBroker()
- uploader := artwork.NewUploader(dataStore)
- playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
+ fileCache := artwork.GetImageCache()
+ fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
- modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
- return modelScanner
+ manager := plugins.GetManager(dataStore, metricsMetrics)
+ agentsAgents := agents.GetAgents(dataStore, manager)
+ provider := external.NewProvider(dataStore, agentsAgents)
+ artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
+ cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
+ broker := events.GetBroker()
+ playlists := core.NewPlaylists(dataStore)
+ scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ return scannerScanner
}
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
- broker := events.GetBroker()
- uploader := artwork.NewUploader(dataStore)
- playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
+ fileCache := artwork.GetImageCache()
+ fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
- modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
- watcher := scanner.GetWatcher(dataStore, modelScanner)
+ manager := plugins.GetManager(dataStore, metricsMetrics)
+ agentsAgents := agents.GetAgents(dataStore, manager)
+ provider := external.NewProvider(dataStore, agentsAgents)
+ artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
+ cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
+ broker := events.GetBroker()
+ playlists := core.NewPlaylists(dataStore)
+ scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ watcher := scanner.GetWatcher(dataStore, scannerScanner)
return watcher
}
@@ -231,46 +192,19 @@ func GetPlaybackServer() playback.PlaybackServer {
return playbackServer
}
-func CreateArtworkWorker() *artwork.Worker {
+func getPluginManager() plugins.Manager {
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)
- broker := events.GetBroker()
- metricsMetrics := metrics.GetPrometheusInstance(dataStore)
- manager := plugins.GetManager(dataStore, broker, metricsMetrics)
+ manager := plugins.GetManager(dataStore, metricsMetrics)
return 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, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
-func GetPluginManager(ctx context.Context) *plugins.Manager {
+func GetPluginManager(ctx context.Context) plugins.Manager {
manager := getPluginManager()
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
return manager
diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go
index c5006e07f..e8759ac53 100644
--- a/cmd/wire_injectors.go
+++ b/cmd/wire_injectors.go
@@ -6,26 +6,21 @@ import (
"context"
"github.com/google/wire"
- "github.com/navidrome/navidrome/adapters/lastfm"
- "github.com/navidrome/navidrome/adapters/listenbrainz"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/core/agents/lastfm"
+ "github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork"
- "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"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"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,31 +31,22 @@ 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,
+ plugins.GetManager,
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(agents.PluginLoader), new(plugins.Manager)),
+ wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
+ wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)),
+ wire.Bind(new(core.Scanner), new(scanner.Scanner)),
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
- wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)),
)
func CreateDataStore() model.DataStore {
@@ -87,18 +73,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 +91,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
))
}
-func CreateJellyfinDiscovery() *jellyfin.Discovery {
- panic(wire.Build(
- allProviders,
- ))
-}
-
func CreateInsights() metrics.Insights {
panic(wire.Build(
allProviders,
@@ -135,7 +103,7 @@ func CreatePrometheus() metrics.Metrics {
))
}
-func CreateScanner(ctx context.Context) model.Scanner {
+func CreateScanner(ctx context.Context) scanner.Scanner {
panic(wire.Build(
allProviders,
))
@@ -153,26 +121,13 @@ func GetPlaybackServer() playback.PlaybackServer {
))
}
-func CreateArtworkWorker() *artwork.Worker {
+func getPluginManager() plugins.Manager {
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,
- ))
-}
-
-func GetPluginManager(ctx context.Context) *plugins.Manager {
+func GetPluginManager(ctx context.Context) plugins.Manager {
manager := getPluginManager()
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
return manager
diff --git a/conf/buildtags/buildtags.go b/conf/buildtags/buildtags.go
new file mode 100644
index 000000000..5fc125087
--- /dev/null
+++ b/conf/buildtags/buildtags.go
@@ -0,0 +1,4 @@
+package buildtags
+
+// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
+// required build tags are disabled.
diff --git a/conf/buildtags/doc.go b/conf/buildtags/doc.go
deleted file mode 100644
index f637b6355..000000000
--- a/conf/buildtags/doc.go
+++ /dev/null
@@ -1,6 +0,0 @@
-// Package buildtags provides compile-time enforcement of required build tags.
-//
-// Each file in this package is guarded by a build constraint and exports a variable
-// that main.go references. If a required tag is missing during compilation, the build
-// fails with an "undefined" error, directing the developer to use `make build`.
-package buildtags
diff --git a/conf/buildtags/netgo.go b/conf/buildtags/netgo.go
index 407004703..0062ad2bc 100644
--- a/conf/buildtags/netgo.go
+++ b/conf/buildtags/netgo.go
@@ -2,6 +2,10 @@
package buildtags
-// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
+// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
+// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
+// file requires it.
+
+// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
var NETGO = true
diff --git a/conf/buildtags/sqlite_fts5.go b/conf/buildtags/sqlite_fts5.go
deleted file mode 100644
index 1476e04cd..000000000
--- a/conf/buildtags/sqlite_fts5.go
+++ /dev/null
@@ -1,8 +0,0 @@
-//go:build sqlite_fts5
-
-package buildtags
-
-// FTS5 is required for full-text search. Without this tag, the SQLite driver
-// won't include FTS5 support, causing runtime failures on migrations and search queries.
-
-var SQLITE_FTS5 = true
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..7292c7dfe 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -1,44 +1,32 @@
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/robfig/cron/v3"
"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
@@ -55,9 +43,7 @@ type configOptions struct {
EnableTranscodingConfig bool
EnableDownloads bool
EnableExternalServices bool
- EnableM3UExternalAlbumArt bool
EnableInsightsCollector bool
- EnableScheduledDBAnalyze bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
ImageCacheSize string
@@ -69,30 +55,22 @@ type configOptions struct {
SmartPlaylistRefreshDelay time.Duration
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
- Search searchOptions `json:",omitzero"`
- Matcher matcherOptions `json:",omitzero"`
+ SearchFullString bool
RecentlyAddedByModTime bool
PreferSortTags bool
- EnableNaturalSorting bool
IgnoredArticles string
IndexGroups string
FFmpegPath string
MPVPath string
MPVCmdTemplate string
CoverArtPriority string
- CoverArtQuality int
- EnableWebPEncoding bool
+ CoverJpegQuality int
ArtistArtPriority string
- ArtistImageFolder string
- DiscArtPriority string
LyricsPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
- EnableArtworkUpload bool
- MaxImageUploadSize string
- MaxImageSize string
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
@@ -100,20 +78,19 @@ type configOptions struct {
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
- UISearchDebounceMs int
- UICoverArtSize int
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
- UIPlaybackReportInterval time.Duration
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
- ExtAuth extAuthOptions
+ ReverseProxyUserHeader string
+ ReverseProxyWhitelist string
Plugins pluginsOptions
- HTTPHeaders httpHeaderOptions `json:",omitzero"`
+ PluginConfig map[string]map[string]string
+ HTTPSecurityHeaders secureOptions `json:",omitzero"`
Prometheus prometheusOptions `json:",omitzero"`
Scanner scannerOptions `json:",omitzero"`
Jukebox jukeboxOptions `json:",omitzero"`
@@ -121,78 +98,58 @@ type configOptions struct {
PID pidOptions `json:",omitzero"`
Inspect inspectOptions `json:",omitzero"`
Subsonic subsonicOptions `json:",omitzero"`
- Transcoding transcodingOptions `json:",omitzero"`
LastFM lastfmOptions `json:",omitzero"`
+ Spotify spotifyOptions `json:",omitzero"`
Deezer deezerOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
- Jellyfin jellyfinOptions `json:",omitzero"`
- EnableScrobbleHistory bool
- Tags map[string]TagConf `json:",omitempty"`
+ Tags map[string]TagConf `json:",omitempty"`
Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features
- DevLogLevels map[string]string `json:",omitempty"`
- DevLogSourceLine bool
- DevEnableProfiler bool
- DevAutoCreateAdminPassword string
- DevAutoLoginUsername string
- DevActivityPanel bool
- DevActivityPanelUpdateRate time.Duration
- DevSidebarPlaylists bool
- DevShowArtistPage bool
- DevUIShowConfig bool
- DevNewEventStream bool
- DevOffsetOptimize int
- DevArtworkMaxRequests int
- DevArtworkThrottleBacklogLimit int
- DevArtworkThrottleBacklogTimeout time.Duration
- DevArtworkThrottleBuffered bool
- DevArtworkWorkerConcurrency int
- DevArtworkExternalMaxRPS int
- DevArtistInfoTimeToLive time.Duration
- DevAlbumInfoTimeToLive time.Duration
- DevExternalScanner bool
- DevScannerThreads uint
- DevSelectiveWatcher bool
- DevInsightsInitialDelay time.Duration
- DevEnablePlayerInsights bool
- DevEnablePluginsInsights bool
- DevPluginCompilationTimeout time.Duration
- DevExternalArtistFetchMultiplier float64
- DevPreserveUnicodeInExternalCalls bool
- DevEnableMediaFileProbe bool
- DevAPIv1 bool
+ DevLogLevels map[string]string `json:",omitempty"`
+ DevLogSourceLine bool
+ DevEnableProfiler bool
+ DevAutoCreateAdminPassword string
+ DevAutoLoginUsername string
+ DevActivityPanel bool
+ DevActivityPanelUpdateRate time.Duration
+ DevSidebarPlaylists bool
+ DevShowArtistPage bool
+ DevUIShowConfig bool
+ DevNewEventStream bool
+ DevOffsetOptimize int
+ DevArtworkMaxRequests int
+ DevArtworkThrottleBacklogLimit int
+ DevArtworkThrottleBacklogTimeout time.Duration
+ DevArtistInfoTimeToLive time.Duration
+ DevAlbumInfoTimeToLive time.Duration
+ DevExternalScanner bool
+ DevScannerThreads uint
+ DevInsightsInitialDelay time.Duration
+ DevEnablePlayerInsights bool
+ DevEnablePluginsInsights bool
+ DevPluginCompilationTimeout time.Duration
+ DevExternalArtistFetchMultiplier float64
}
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 {
AppendSubtitle bool
- AppendAlbumVersion bool
ArtistParticipations bool
DefaultReportRealPath bool
- EnableAverageRating bool
LegacyClients string
- MinimalClients string
}
type TagConf struct {
@@ -206,52 +163,34 @@ type TagConf struct {
type lastfmOptions struct {
Enabled bool
- ApiKey string //nolint:gosec
- Secret string //nolint:gosec
+ ApiKey string
+ Secret string
Language string
ScrobbleFirstArtistOnly bool
+}
- // Computed values
- Languages []string `conf:"-"` // Computed from Language, split by comma
+type spotifyOptions struct {
+ ID string
+ Secret string
}
type deezerOptions struct {
- Enabled bool
- Language string
-
- // Computed values
- Languages []string `conf:"-"` // Computed from Language, split by comma
+ Enabled bool
}
type listenBrainzOptions struct {
- Enabled bool
- BaseURL string
- ArtistAlgorithm string
- TrackAlgorithm string
+ Enabled bool
+ BaseURL 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
+type secureOptions struct {
+ CustomFrameOptionsValue string
}
type prometheusOptions struct {
Enabled bool
MetricsPath string
- Password string //nolint:gosec
+ Password string
}
type AudioDeviceDefinition []string
@@ -265,7 +204,7 @@ type jukeboxOptions struct {
type backupOptions struct {
Count int
- Path Dir
+ Path string
Schedule string
}
@@ -282,46 +221,9 @@ type inspectOptions struct {
}
type pluginsOptions struct {
- Enabled bool
- Folder Dir
- CacheSize string
- AutoReload bool
- LogLevel string
-}
-
-type extAuthOptions struct {
- TrustedSources string
- UserHeader string
- LogoutURL string
-}
-
-type searchOptions struct {
- Backend string
- FullString bool
-}
-
-type matcherOptions struct {
- PreferStarred bool
- FuzzyThreshold int
-}
-
-// logFatal prints a fatal error message to stderr and exits.
-// Overridden in tests to allow testing fatal paths.
-var logFatal = func(args ...any) {
- _, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...)
- os.Exit(1)
-}
-
-var getEUID = os.Geteuid
-
-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 != ""
+ Enabled bool
+ Folder string
+ CacheSize string
}
var (
@@ -329,98 +231,72 @@ var (
hooks []func()
)
-// SnapshotConfig returns a function that restores Server to its current state.
-// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
-func SnapshotConfig() func() {
- snapshot, err := json.Marshal(Server)
- if err != nil {
- panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
- }
- return func() {
- var restored configOptions
- if err := json.Unmarshal(snapshot, &restored); err != nil {
- panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
- }
- Server = &restored
- }
-}
-
func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
if err != nil {
- logFatal("Error reading config file:", err)
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
+ os.Exit(1)
}
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)
- }
- }
-
- 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)
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
+ os.Exit(1)
}
- // Validate non-root user early, before any filesystem operations
- if err := validateEnforceNonRootUser(); err != nil {
- logFatal(err)
+ err = os.MkdirAll(Server.DataFolder, os.ModePerm)
+ if err != nil {
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
+ os.Exit(1)
}
- if Server.CacheFolder.String() == "" {
- Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
+ if Server.CacheFolder == "" {
+ Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
+ }
+ err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
+ if err != nil {
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
+ os.Exit(1)
}
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 {
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
+ os.Exit(1)
}
}
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 {
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
+ os.Exit(1)
+ }
}
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()))
+ _, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
+ os.Exit(1)
}
log.SetOutput(out)
- } else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
- // When running under systemd, prepend syslog priority prefixes so
- // journald assigns the correct severity to each log line.
- // Note that we have an additional environment variable, as JOURNAL_STREAM
- // can be present in a systemd environment even if not running as a systemd service
- log.EnableJournalFormat()
}
log.SetLevelString(Server.LogLevel)
@@ -428,46 +304,21 @@ 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),
- validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
)
if err != nil {
- logFatal(err)
+ os.Exit(1)
}
- Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
-
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
- logFatal("Invalid BaseURL:", err)
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
+ os.Exit(1)
}
Server.BasePath = u.Path
u.Path = ""
@@ -476,18 +327,9 @@ func Load(noConfigDump bool) {
Server.BaseScheme = u.Scheme
}
- // Log configuration source
- if Server.ConfigFile != "" {
- log.Info("Loaded configuration", "file", Server.ConfigFile)
- } else if hasNDEnvVars() {
- log.Info("No configuration file found. Loaded configuration only from environment variables")
- } else {
- log.Warn("No configuration file found. Using default values. To specify a config file, use the --configfile flag or set the ND_CONFIGFILE environment variable.")
- }
-
// Print current configuration if log level is Debug
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
- prettyConf := pretty.Sprintf("Configuration: %# v", Server)
+ prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf)
}
@@ -498,35 +340,13 @@ func Load(noConfigDump bool) {
disableExternalServices()
}
- // Make sure we don't have empty PIDs
- Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID)
- Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID)
-
- // Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
- Server.LastFM.Languages = parseLanguages(Server.LastFM.Language)
-
- // Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
- Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
-
- // Validate other options
- if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
- newValue := max(200, min(1200, Server.UICoverArtSize))
- log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
- Server.UICoverArtSize = newValue
- }
-
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
- log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
+ log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
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
- }
+ logDeprecatedOptions("Scanner.GenreSeparators")
+ logDeprecatedOptions("Scanner.GroupAlbumReleases")
+ logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
// Call init hooks
for _, hook := range hooks {
@@ -534,271 +354,39 @@ func Load(noConfigDump bool) {
}
}
-// 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)
- 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))
- } else {
- log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", oldName))
- }
- }
- if os.Getenv(envVar) != "" {
- logWarning(envVar, newEnvVar)
- }
- if viper.InConfig(oldName) {
- logWarning(oldName, newName)
- }
-}
-
-// logRemovedOptions checks if the option is set, and if yes, outputs a warning message saying the option is
-// not available anymore
-func logRemovedOptions(options ...string) {
+func logDeprecatedOptions(options ...string) {
for _, option := range options {
- envVar := envVarName(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))
+ envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
+ if os.Getenv(envVar) != "" {
+ log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", envVar))
}
if viper.InConfig(option) {
- logWarning(option)
- }
- if os.Getenv(envVar) != "" {
- logWarning(envVar)
+ log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", option))
}
}
}
-// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly
-// using environment variable names) and remaps them to canonical Viper keys with a warning.
-func remapEnvVarKeysFromConfig() {
- for _, key := range viper.AllKeys() {
- if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) {
- continue
- }
- stripped := strings.TrimPrefix(key, "nd_")
- canonicalKey := ndKeyToCanonical(key)
- displayNDKey := "ND_" + strings.ToUpper(stripped)
- canonicalName := canonicalOptionName(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)),
- ))
- 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,
- )
- }
- }
-}
-
-// 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) {
- 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.
func parseIniFileConfiguration() {
cfgFile := viper.ConfigFileUsed()
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
- var iniConfig map[string]any
+ var iniConfig map[string]interface{}
err := viper.Unmarshal(&iniConfig)
if err != nil {
- logFatal("Error parsing config:", err)
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
+ os.Exit(1)
}
cfg, ok := iniConfig["default"].(map[string]any)
if !ok {
- logFatal("Error parsing config: missing [default] section:", iniConfig)
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
+ os.Exit(1)
}
err = viper.MergeConfigMap(cfg)
if err != nil {
- logFatal("Error parsing config:", err)
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
+ os.Exit(1)
}
}
}
@@ -806,8 +394,8 @@ func parseIniFileConfiguration() {
func disableExternalServices() {
log.Info("All external integrations are DISABLED!")
Server.EnableInsightsCollector = false
- Server.EnableM3UExternalAlbumArt = false
Server.LastFM.Enabled = false
+ Server.Spotify.ID = ""
Server.Deezer.Enabled = false
Server.ListenBrainz.Enabled = false
Server.Agents = ""
@@ -817,70 +405,34 @@ func disableExternalServices() {
}
func validatePlaylistsPath() error {
- for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
+ for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) {
_, err := doublestar.Match(path, "")
if err != nil {
- return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err)
+ log.Error("Invalid PlaylistsPath", "path", path, err)
+ return err
}
}
return nil
}
-// parseLanguages parses a comma-separated language string into a slice.
-// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
-func parseLanguages(lang string) []string {
- var languages []string
- for l := range strings.SplitSeq(lang, ",") {
- l = strings.TrimSpace(l)
- if l != "" {
- languages = append(languages, l)
- }
- }
- if len(languages) == 0 {
- return []string{consts.DefaultInfoLanguage}
- }
- return languages
-}
-
func validatePurgeMissingOption() error {
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
- valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
+ valid := false
+ for _, v := range allowedValues {
+ if v == Server.Scanner.PurgeMissing {
+ valid = true
+ break
+ }
+ }
if !valid {
- err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
+ err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
+ log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err
}
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 validateEnforceNonRootUser() error {
- if !Server.EnforceNonRootUser || currentGOOS() == "windows" {
- return nil
- }
-
- if getEUID() == 0 {
- return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root")
- }
-
- return nil
-}
-
func validateScanSchedule() error {
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
Server.Scanner.Schedule = ""
@@ -892,7 +444,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
}
@@ -902,58 +454,17 @@ func validateBackupSchedule() error {
}
func validateSchedule(schedule, field string) (string, error) {
- _, err := scheduler.ParseCrontab(schedule)
+ if _, err := time.ParseDuration(schedule); err == nil {
+ schedule = "@every " + schedule
+ }
+ c := cron.New()
+ id, err := c.AddFunc(schedule, func() {})
if err != nil {
- return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err)
+ log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
+ } else {
+ c.Remove(id)
}
- return schedule, nil
-}
-
-// validateURL checks if the provided URL is valid and has either http or https scheme.
-// It returns a function that can be used as a hook to validate URLs in the config.
-func validateURL(optionName, optionURL string) func() error {
- return func() error {
- if optionURL == "" {
- return nil
- }
- u, err := url.Parse(optionURL)
- if err != nil {
- return fmt.Errorf("invalid %s %q: %w", optionName, optionURL, err)
- }
- if u.Scheme != "http" && u.Scheme != "https" {
- return fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
- }
- if u.Host == "" || u.Opaque != "" {
- return fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
- }
- return nil
- }
-}
-
-func normalizeSearchBackend(value string) string {
- v := strings.ToLower(strings.TrimSpace(value))
- switch v {
- case "fts", "legacy":
- return v
- default:
- log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value)
- return "fts"
- }
-}
-
-// toPascalCase converts a dotted lowercase config key to PascalCase for display.
-// Example: "scanner.schedule" → "Scanner.Schedule"
-func toPascalCase(key string) string {
- if key == "" {
- return ""
- }
- parts := strings.Split(key, ".")
- for i, part := range parts {
- if len(part) > 0 {
- parts[i] = strings.ToUpper(part[:1]) + part[1:]
- }
- }
- return strings.Join(parts, ".")
+ return schedule, err
}
// AddHook is used to register initialization code that should run as soon as the config is loaded
@@ -961,16 +472,6 @@ func AddHook(hook func()) {
hooks = append(hooks, hook)
}
-// hasNDEnvVars checks if any ND_ prefixed environment variables are set (excluding ND_CONFIGFILE)
-func hasNDEnvVars() bool {
- for _, env := range os.Environ() {
- if strings.HasPrefix(env, "ND_") && !strings.HasPrefix(env, "ND_CONFIGFILE=") {
- return true
- }
- }
- return false
-}
-
func setViperDefaults() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
@@ -980,7 +481,6 @@ func setViperDefaults() {
viper.SetDefault("address", "0.0.0.0")
viper.SetDefault("port", 4533)
viper.SetDefault("unixsocketperm", "0660")
- viper.SetDefault("enforcenonrootuser", false)
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
viper.SetDefault("baseurl", "")
viper.SetDefault("tlscert", "")
@@ -996,32 +496,23 @@ 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)
viper.SetDefault("enablemediafilecoverart", true)
viper.SetDefault("autotranscodedownload", false)
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
- viper.SetDefault("search.fullstring", false)
- viper.SetDefault("search.backend", "fts")
- viper.SetDefault("matcher.preferstarred", true)
- viper.SetDefault("matcher.fuzzythreshold", 85)
+ viper.SetDefault("searchfullstring", false)
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", "")
- viper.SetDefault("mpvpath", "")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
- viper.SetDefault("coverartquality", 75)
- viper.SetDefault("enablewebpencoding", false)
+ viper.SetDefault("coverjpegquality", 75)
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)
@@ -1029,29 +520,21 @@ func setViperDefaults() {
viper.SetDefault("defaulttheme", "Dark")
viper.SetDefault("defaultlanguage", "")
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
- viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
- viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
- 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", "")
- viper.SetDefault("extauth.logouturl", "")
+ viper.SetDefault("reverseproxyuserheader", "Remote-User")
+ viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
@@ -1065,40 +548,26 @@ 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)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
- 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("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true)
- viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
+ viper.SetDefault("lastfm.language", "en")
viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "")
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
+ viper.SetDefault("spotify.id", "")
+ viper.SetDefault("spotify.secret", "")
viper.SetDefault("deezer.enabled", true)
- viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
viper.SetDefault("listenbrainz.enabled", true)
- 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("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
+ viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
viper.SetDefault("backup.count", 0)
@@ -1109,10 +578,8 @@ func setViperDefaults() {
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("plugins.folder", "")
- viper.SetDefault("plugins.enabled", true)
- viper.SetDefault("plugins.cachesize", "200MB")
- viper.SetDefault("plugins.autoreload", false)
- viper.SetDefault("plugins.loglevel", "")
+ viper.SetDefault("plugins.enabled", false)
+ viper.SetDefault("plugins.cachesize", "100MB")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
@@ -1125,47 +592,28 @@ 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("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
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)
viper.SetDefault("devscannerthreads", 5)
- viper.SetDefault("devselectivewatcher", true)
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
viper.SetDefault("devenableplayerinsights", true)
viper.SetDefault("devenablepluginsinsights", true)
viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
- viper.SetDefault("devpreserveunicodeinexternalcalls", false)
- viper.SetDefault("devenablemediafileprobe", true)
}
func init() {
setViperDefaults()
}
-func InitConfig(cfgFile string, loadEnvVars bool) {
+func InitConfig(cfgFile string) {
codecRegistry := viper.NewCodecRegistry()
- _ = codecRegistry.RegisterCodec("ini", ini.Codec{
- LoadOptions: ini.LoadOptions{
- UnescapeValueDoubleQuotes: true,
- UnescapeValueCommentSymbols: true,
- },
- })
+ _ = codecRegistry.RegisterCodec("ini", ini.Codec{})
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
cfgFile = getConfigFile(cfgFile)
@@ -1179,16 +627,15 @@ func InitConfig(cfgFile string, loadEnvVars bool) {
}
_ = viper.BindEnv("port")
- if loadEnvVars {
- viper.SetEnvPrefix("ND")
- replacer := strings.NewReplacer(".", "_")
- viper.SetEnvKeyReplacer(replacer)
- viper.AutomaticEnv()
- }
+ viper.SetEnvPrefix("ND")
+ replacer := strings.NewReplacer(".", "_")
+ viper.SetEnvKeyReplacer(replacer)
+ viper.AutomaticEnv()
err := viper.ReadInConfig()
if viper.ConfigFileUsed() != "" && err != nil {
- logFatal("Navidrome could not open config file:", err)
+ _, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
+ os.Exit(1)
}
}
@@ -1200,20 +647,9 @@ func getConfigFile(cfgFile string) string {
}
cfgFile = os.Getenv("ND_CONFIGFILE")
if cfgFile != "" {
- if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
+ if _, err := os.Stat(cfgFile); err == nil {
return cfgFile
}
}
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..5b54e4975 100644
--- a/conf/configuration_test.go
+++ b/conf/configuration_test.go
@@ -1,18 +1,11 @@
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"
@@ -31,439 +24,6 @@ var _ = Describe("Configuration", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
-
- // Panic instead of exiting on fatal errors to allow testing error conditions
- DeferCleanup(conf.SetLogFatal(func(args ...any) {
- panic(fmt.Sprint(args...))
- }))
- })
-
- Describe("ParseLanguages", func() {
- It("parses single language", func() {
- Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"}))
- })
-
- It("parses multiple comma-separated languages", func() {
- Expect(conf.ParseLanguages("pt,en")).To(Equal([]string{"pt", "en"}))
- })
-
- It("trims whitespace from languages", func() {
- Expect(conf.ParseLanguages(" pt , en ")).To(Equal([]string{"pt", "en"}))
- })
-
- It("returns default 'en' when empty", func() {
- Expect(conf.ParseLanguages("")).To(Equal([]string{"en"}))
- })
-
- It("returns default 'en' when only whitespace", func() {
- Expect(conf.ParseLanguages(" ")).To(Equal([]string{"en"}))
- })
-
- It("handles multiple languages with various spacing", func() {
- Expect(conf.ParseLanguages("ja, pt, en")).To(Equal([]string{"ja", "pt", "en"}))
- })
- })
-
- 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")
- Expect(fn()).To(Succeed())
- })
-
- It("accepts a valid https URL", func() {
- fn := conf.ValidateURL("TestOption", "https://example.com/path")
- Expect(fn()).To(Succeed())
- })
-
- It("rejects a URL with no scheme", func() {
- fn := conf.ValidateURL("TestOption", "example.com/path")
- Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
- })
-
- It("rejects a URL with an unsupported scheme", func() {
- fn := conf.ValidateURL("TestOption", "javascript://example.com/path")
- Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
- })
-
- It("accepts an empty URL (optional config)", func() {
- fn := conf.ValidateURL("TestOption", "")
- Expect(fn()).To(Succeed())
- })
-
- It("includes the option name in the error message", func() {
- fn := conf.ValidateURL("MyOption", "ftp://example.com")
- Expect(fn()).To(MatchError(ContainSubstring("MyOption")))
- })
-
- It("rejects a URL that cannot be parsed", func() {
- fn := conf.ValidateURL("TestOption", "://invalid")
- Expect(fn()).To(HaveOccurred())
- })
-
- It("rejects a URL without a host", func() {
- fn := conf.ValidateURL("TestOption", "http:///path")
- Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required")))
- })
- })
-
- DescribeTable("NormalizeSearchBackend",
- func(input, expected string) {
- Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected))
- },
- Entry("accepts 'fts'", "fts", "fts"),
- Entry("accepts 'legacy'", "legacy", "legacy"),
- Entry("normalizes 'FTS' to lowercase", "FTS", "fts"),
- Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"),
- Entry("trims whitespace", " fts ", "fts"),
- Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"),
- Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"),
- Entry("falls back to 'fts' for empty string", "", "fts"),
- )
-
- DescribeTable("ToPascalCase",
- func(input, expected string) {
- Expect(conf.ToPascalCase(input)).To(Equal(expected))
- },
- Entry("simple key", "address", "Address"),
- Entry("dotted key", "scanner.schedule", "Scanner.Schedule"),
- Entry("already capitalized", "Address", "Address"),
- Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"),
- Entry("empty string", "", ""),
- )
-
- Describe("remapEnvVarKeysFromConfig", func() {
- BeforeEach(func() {
- viper.Reset()
- conf.SetViperDefaults()
- viper.SetDefault("datafolder", GinkgoT().TempDir())
- viper.SetDefault("loglevel", "error")
- conf.ResetConf()
- })
-
- It("remaps ND_-prefixed keys to canonical keys", func() {
- filename := filepath.Join("testdata", "cfg_nd_keys.toml")
- conf.InitConfig(filename, false)
- conf.Load(true)
-
- Expect(conf.Server.Address).To(Equal("127.0.0.1"))
- Expect(conf.Server.Port).To(Equal(4531))
- Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
- })
-
- It("exits with fatal error when both ND_ and canonical key exist", func() {
- filename := filepath.Join("testdata", "cfg_nd_conflict.toml")
- conf.InitConfig(filename, false)
-
- Expect(func() { conf.Load(true) }).To(PanicWith(And(
- ContainSubstring("ND_ADDRESS"),
- ContainSubstring("Address"),
- ContainSubstring("only needed for environment variables"),
- )))
- })
-
- It("does nothing when no ND_ keys are present", func() {
- filename := filepath.Join("testdata", "cfg.toml")
- conf.InitConfig(filename, false)
- conf.Load(true)
-
- // Verify normal config loading still works
- Expect(conf.Server.MusicFolder).To(Equal("/toml/music"))
- })
- })
-
- 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() {
- viper.Reset()
- conf.SetViperDefaults()
- viper.SetDefault("loglevel", "error")
- conf.ResetConf()
-
- // Create a file so that any path under it is invalid on all OSes
- f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker")
- Expect(err).ToNot(HaveOccurred())
- f.Close()
- invalidPath = filepath.Join(f.Name(), "subdir")
- })
-
- It("is called when LoadFromFile gets an invalid config file", func() {
- Expect(func() {
- conf.LoadFromFile(filepath.Join(invalidPath, "file.toml"))
- }).To(PanicWith(ContainSubstring("Error reading config file")))
- })
-
- 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)))
- })
-
- It("is called when BaseURL is invalid", func() {
- viper.SetDefault("datafolder", GinkgoT().TempDir())
- viper.SetDefault("baseurl", "://invalid")
- Expect(func() {
- conf.Load(true)
- }).To(PanicWith(ContainSubstring("Invalid BaseURL")))
- })
-
- })
-
- Describe("ValidateByteSize", func() {
- DescribeTable("accepts valid size values",
- func(input string) {
- Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed())
- },
- Entry("megabytes", "10MB"),
- Entry("gigabytes", "1GB"),
- Entry("raw bytes", "10485760"),
- Entry("mebibytes", "10MiB"),
- Entry("lower case", "50mb"),
- )
-
- DescribeTable("rejects invalid size values",
- func(input string) {
- Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize")))
- },
- 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)
-
- Expect(conf.Server.EnforceNonRootUser).To(BeFalse())
- })
-
- It("allows startup for non-root users when enabled", func() {
- DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000))
- viper.Set("enforcenonrootuser", true)
-
- conf.Load(true)
-
- Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
- })
-
- It("exits when enabled and running as root without having created a data folder", func() {
- // Create a path that doesn't exist yet
- tempBase := GinkgoT().TempDir()
- nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data")
- DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0))
- viper.Set("enforcenonrootuser", true)
- viper.Set("datafolder", nonExistentDataFolder)
-
- // Attempt to load config as root user - should fail before creating directories
- Expect(func() {
- conf.Load(true)
- }).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root")))
-
- // Verify that the data folder was NOT created
- Expect(nonExistentDataFolder).ToNot(BeAnExistingFile())
- })
-
- It("is a no-op on non-unix platforms", func() {
- DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0))
- viper.Set("enforcenonrootuser", true)
-
- conf.Load(true)
-
- Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
- })
})
DescribeTable("should load configuration from",
@@ -471,7 +31,7 @@ var _ = Describe("Configuration", func() {
filename := filepath.Join("testdata", "cfg."+format)
// Initialize config with the test file
- conf.InitConfig(filename, false)
+ conf.InitConfig(filename)
// Load the configuration (with noConfigDump=true)
conf.Load(true)
@@ -479,10 +39,6 @@ var _ = Describe("Configuration", func() {
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
- Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
-
- // Check deprecated option mapping
- Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User"))
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))
@@ -492,73 +48,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..1b6daf036 100644
--- a/conf/export_test.go
+++ b/conf/export_test.go
@@ -5,34 +5,3 @@ func ResetConf() {
}
var SetViperDefaults = setViperDefaults
-
-var ParseLanguages = parseLanguages
-
-var ValidateURL = validateURL
-
-var NormalizeSearchBackend = normalizeSearchBackend
-
-var ToPascalCase = toPascalCase
-
-var ValidateByteSize = validateByteSize
-
-func SetRuntimeInfoForTest(goos string, euid int) func() {
- oldGOOS := currentGOOS
- oldEUID := getEUID
- currentGOOS = func() string { return goos }
- getEUID = func() int { return euid }
- return func() {
- currentGOOS = oldGOOS
- getEUID = oldEUID
- }
-}
-
-func SetLogFatal(f func(...any)) func() {
- old := logFatal
- logFatal = f
- return func() { logFatal = old }
-}
-
-var UnknownConfigKeys = unknownConfigKeys
-
-var SuggestOptions = suggestOptions
diff --git a/conf/testdata/cfg.ini b/conf/testdata/cfg.ini
index cc8b2a4a5..cec7d3c70 100644
--- a/conf/testdata/cfg.ini
+++ b/conf/testdata/cfg.ini
@@ -1,8 +1,6 @@
[default]
MusicFolder = /ini/music
-UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions
-ReverseProxyUserHeader = 'X-Auth-User'
+UIWelcomeMessage = Welcome ini
[Tags]
-Custom.Aliases = ini,test
-artist.Split = ";" # Should be able to read ; as a separator
\ No newline at end of file
+Custom.Aliases = ini,test
\ No newline at end of file
diff --git a/conf/testdata/cfg.json b/conf/testdata/cfg.json
index 28fb039d2..37cf74f08 100644
--- a/conf/testdata/cfg.json
+++ b/conf/testdata/cfg.json
@@ -1,11 +1,7 @@
{
"musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json",
- "reverseProxyUserHeader": "X-Auth-User",
"Tags": {
- "artist": {
- "split": ";"
- },
"custom": {
"aliases": [
"json",
diff --git a/conf/testdata/cfg.toml b/conf/testdata/cfg.toml
index 589e2a100..1dc852b18 100644
--- a/conf/testdata/cfg.toml
+++ b/conf/testdata/cfg.toml
@@ -1,8 +1,5 @@
musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml"
-ReverseProxyUserHeader = "X-Auth-User"
-
-Tags.artist.Split = ';'
[Tags.custom]
aliases = ["toml", "test"]
diff --git a/conf/testdata/cfg.yaml b/conf/testdata/cfg.yaml
index e44d2ebbb..38b98d4aa 100644
--- a/conf/testdata/cfg.yaml
+++ b/conf/testdata/cfg.yaml
@@ -1,9 +1,6 @@
musicFolder: "/yaml/music"
uiWelcomeMessage: "Welcome yaml"
-reverseProxyUserHeader: "X-Auth-User"
Tags:
- artist:
- split: [";"]
custom:
aliases:
- yaml
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_nd_conflict.toml b/conf/testdata/cfg_nd_conflict.toml
deleted file mode 100644
index 2e8b94bc3..000000000
--- a/conf/testdata/cfg_nd_conflict.toml
+++ /dev/null
@@ -1,2 +0,0 @@
-ND_ADDRESS = "127.0.0.1"
-Address = "0.0.0.0"
diff --git a/conf/testdata/cfg_nd_keys.toml b/conf/testdata/cfg_nd_keys.toml
deleted file mode 100644
index de441ce66..000000000
--- a/conf/testdata/cfg_nd_keys.toml
+++ /dev/null
@@ -1,3 +0,0 @@
-ND_ADDRESS = "127.0.0.1"
-ND_PORT = 4531
-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..fbb2c9429 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,14 +51,11 @@ const (
DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline
DefaultMaxSidebarPlaylists = 100
- DefaultAuthWindowLength = 20 * time.Second
RequestThrottleBacklogLimit = 100
RequestThrottleBacklogTimeout = time.Minute
ServerReadHeaderTimeout = 3 * time.Second
- DefaultInfoLanguage = "en"
-
ArtistInfoTimeToLive = 24 * time.Hour
AlbumInfoTimeToLive = 7 * 24 * time.Hour
UpdateLastAccessFrequency = time.Minute
@@ -90,36 +63,18 @@ 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"
- PlaceholderAvatar = "logo-192x192.png"
- DefaultUIVolume = 100
- DefaultUISearchDebounceMs = 200
- DefaultUIPlaybackReportInterval = time.Minute
+ PlaceholderArtistArt = "artist-placeholder.webp"
+ PlaceholderAlbumArt = "album-placeholder.webp"
+ PlaceholderAvatar = "logo-192x192.png"
+ UICoverArtSize = 300
+ DefaultUIVolume = 100
DefaultHttpClientTimeOut = 10 * time.Second
- DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/"
- DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
- DefaultListenBrainzTrackAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
-
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
@@ -140,13 +95,6 @@ const (
DefaultCacheCleanUpInterval = 10 * time.Minute
)
-// Entity types
-const (
- EntityArtist = "artist"
- EntityPlaylist = "playlist"
- EntityRadio = "radio"
-)
-
const (
AlbumPlayCountModeAbsolute = "absolute"
AlbumPlayCountModeNormalized = "normalized"
@@ -156,6 +104,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,31 +133,23 @@ 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 -",
- },
- {
- 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 -b:a %bk -v 0 -c:a aac -f adts -",
},
}
)
-var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome"
-
var (
VariousArtists = "Various Artists"
// TODO This will be dynamic when using disambiguation
diff --git a/context7.json b/context7.json
deleted file mode 100644
index 343873063..000000000
--- a/context7.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "url": "https://context7.com/navidrome/navidrome",
- "public_key": "pk_WqzhKScNKWQ84J4n0oG0J"
-}
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/README.md b/core/agents/README.md
index cce62889c..1a3a8e96e 100644
--- a/core/agents/README.md
+++ b/core/agents/README.md
@@ -7,6 +7,6 @@ A new agent must comply with these simple implementation rules:
2) Implement one or more of the `*Retriever()` interfaces. That's where the agent's logic resides.
3) Register itself (in its `init()` function).
-For an agent to be used it needs to be listed in the `Agents` config option (default is `"deezer,lastfm"`). The order dictates the priority of the agents
+For an agent to be used it needs to be listed in the `Agents` config option (default is `"lastfm,spotify"`). The order dictates the priority of the agents
For a simple Agent example, look at the [local_agent](local_agent.go) agent source code.
diff --git a/core/agents/agents.go b/core/agents/agents.go
index 8ae3124ef..4ec324b71 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,9 @@ 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 +39,6 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
return &Agents{
ds: ds,
pluginLoader: pluginLoader,
- cooldowns: cooldowns{until: map[string]time.Time{}},
}
}
@@ -103,7 +64,6 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
if a.pluginLoader != nil {
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
}
- log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
configuredAgents := strings.Split(conf.Server.Agents, ",")
@@ -127,19 +87,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.Warn("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 +121,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:
@@ -211,14 +128,26 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
case consts.VariousArtistsID:
return "", nil
}
-
- return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
+ start := time.Now()
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
+ if ag == nil {
+ continue
+ }
+ if utils.IsCtxDone(ctx) {
+ break
+ }
retriever, ok := ag.(ArtistMBIDRetriever)
if !ok {
- return "", errUnsupported
+ continue
}
- return retriever.GetArtistMBID(ctx, id, name)
- })
+ mbid, err := retriever.GetArtistMBID(ctx, id, name)
+ if mbid != "" && err == nil {
+ log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
+ return mbid, nil
+ }
+ }
+ return "", ErrNotFound
}
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
@@ -228,14 +157,26 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
case consts.VariousArtistsID:
return "", nil
}
-
- return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
+ start := time.Now()
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
+ if ag == nil {
+ continue
+ }
+ if utils.IsCtxDone(ctx) {
+ break
+ }
retriever, ok := ag.(ArtistURLRetriever)
if !ok {
- return "", errUnsupported
+ continue
}
- return retriever.GetArtistURL(ctx, id, name, mbid)
- })
+ url, err := retriever.GetArtistURL(ctx, id, name, mbid)
+ if url != "" && err == nil {
+ log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
+ return url, nil
+ }
+ }
+ return "", ErrNotFound
}
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
@@ -245,14 +186,26 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
case consts.VariousArtistsID:
return "", nil
}
-
- return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
+ start := time.Now()
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
+ if ag == nil {
+ continue
+ }
+ if utils.IsCtxDone(ctx) {
+ break
+ }
retriever, ok := ag.(ArtistBiographyRetriever)
if !ok {
- return "", errUnsupported
+ continue
}
- return retriever.GetArtistBiography(ctx, id, name, mbid)
- })
+ bio, err := retriever.GetArtistBiography(ctx, id, name, mbid)
+ if err == nil {
+ log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
+ return bio, nil
+ }
+ }
+ return "", ErrNotFound
}
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
@@ -268,11 +221,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 +234,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 +243,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) {
@@ -305,14 +253,26 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
case consts.VariousArtistsID:
return nil, nil
}
-
- return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
+ start := time.Now()
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
+ if ag == nil {
+ continue
+ }
+ if utils.IsCtxDone(ctx) {
+ break
+ }
retriever, ok := ag.(ArtistImageRetriever)
if !ok {
- return nil, errUnsupported
+ continue
}
- return retriever.GetArtistImages(ctx, id, name, mbid)
- })
+ images, err := retriever.GetArtistImages(ctx, id, name, mbid)
+ if len(images) > 0 && err == nil {
+ log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
+ return images, nil
+ }
+ }
+ return nil, ErrNotFound
}
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
@@ -327,163 +287,80 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
- return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
- retriever, ok := ag.(ArtistTopSongsRetriever)
- if !ok {
- return nil, errUnsupported
- }
- return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
- })
-}
-
-func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
- if name == consts.UnknownAlbum {
- return nil, ErrNotFound
- }
-
- return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
- retriever, ok := ag.(AlbumInfoRetriever)
- if !ok {
- return nil, errUnsupported
- }
- return retriever.GetAlbumInfo(ctx, name, artist, mbid)
- })
-}
-
-func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
- if name == consts.UnknownAlbum {
- return nil, ErrNotFound
- }
-
- return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
- retriever, ok := ag.(AlbumImageRetriever)
- if !ok {
- return nil, errUnsupported
- }
- return retriever.GetAlbumImages(ctx, name, artist, mbid)
- })
-}
-
-// GetSimilarSongsByTrack returns similar songs for a given track.
-func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
- return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) {
- retriever, ok := ag.(SimilarSongsByTrackRetriever)
- if !ok {
- return nil, errUnsupported
- }
- return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
- })
-}
-
-// GetSimilarSongsByAlbum returns similar songs for a given album.
-func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
- return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) {
- retriever, ok := ag.(SimilarSongsByAlbumRetriever)
- if !ok {
- return nil, errUnsupported
- }
- return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
- })
-}
-
-// GetSimilarSongsByArtist returns similar songs for a given artist.
-func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error) {
- switch id {
- case consts.UnknownArtistID:
- return nil, ErrNotFound
- case consts.VariousArtistsID:
- return nil, nil
- }
-
- return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) {
- retriever, ok := ag.(SimilarSongsByArtistRetriever)
- if !ok {
- return nil, errUnsupported
- }
- 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) {
- 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)
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
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)
+ retriever, ok := ag.(ArtistTopSongsRetriever)
+ if !ok {
continue
}
-
- if found(result) {
- log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
- return result, nil
+ songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
+ if len(songs) > 0 && err == nil {
+ log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
+ return songs, nil
}
}
- return zero, attempts.noResultErr()
+ return nil, ErrNotFound
}
-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
- })
+func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
+ if name == consts.UnknownAlbum {
+ return nil, ErrNotFound
+ }
+ start := time.Now()
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
+ if ag == nil {
+ continue
+ }
+ if utils.IsCtxDone(ctx) {
+ break
+ }
+ retriever, ok := ag.(AlbumInfoRetriever)
+ if !ok {
+ continue
+ }
+ album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid)
+ if err == nil {
+ log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist,
+ "mbid", mbid, "elapsed", time.Since(start))
+ return album, nil
+ }
+ }
+ return nil, 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 })
+func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
+ if name == consts.UnknownAlbum {
+ return nil, ErrNotFound
+ }
+ start := time.Now()
+ for _, enabledAgent := range a.getEnabledAgentNames() {
+ ag := a.getAgent(enabledAgent)
+ if ag == nil {
+ continue
+ }
+ if utils.IsCtxDone(ctx) {
+ break
+ }
+ retriever, ok := ag.(AlbumImageRetriever)
+ if !ok {
+ continue
+ }
+ images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
+ if len(images) > 0 && err == nil {
+ log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
+ "mbid", mbid, "elapsed", time.Since(start))
+ return images, nil
+ }
+ }
+ return nil, ErrNotFound
}
var _ Interface = (*Agents)(nil)
@@ -495,6 +372,3 @@ var _ ArtistImageRetriever = (*Agents)(nil)
var _ ArtistTopSongsRetriever = (*Agents)(nil)
var _ AlbumInfoRetriever = (*Agents)(nil)
var _ AlbumImageRetriever = (*Agents)(nil)
-var _ SimilarSongsByTrackRetriever = (*Agents)(nil)
-var _ SimilarSongsByAlbumRetriever = (*Agents)(nil)
-var _ SimilarSongsByArtistRetriever = (*Agents)(nil)
diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go
index 6163c7f3c..0b7eec282 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{{
@@ -432,137 +295,12 @@ var _ = Describe("Agents", func() {
Expect(mock.Args).To(BeEmpty())
})
})
-
- Describe("GetSimilarSongsByTrack", func() {
- It("returns on first match", func() {
- Expect(ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)).To(Equal([]Song{{
- Name: "Similar Song",
- MBID: "mbid555",
- }}))
- Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
- })
- It("skips the agent if it returns an error", func() {
- mock.Err = errors.New("error")
- _, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
- Expect(err).To(MatchError(ErrNotFound))
- Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
- })
- It("interrupts if the context is canceled", func() {
- cancel()
- _, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
- Expect(err).To(MatchError(ErrNotFound))
- Expect(mock.Args).To(BeEmpty())
- })
- })
-
- Describe("GetSimilarSongsByAlbum", func() {
- It("returns on first match", func() {
- Expect(ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)).To(Equal([]Song{{
- Name: "Album Similar Song",
- MBID: "mbid666",
- }}))
- Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
- })
- It("skips the agent if it returns an error", func() {
- mock.Err = errors.New("error")
- _, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
- Expect(err).To(MatchError(ErrNotFound))
- Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
- })
- It("interrupts if the context is canceled", func() {
- cancel()
- _, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
- Expect(err).To(MatchError(ErrNotFound))
- Expect(mock.Args).To(BeEmpty())
- })
- })
-
- Describe("GetSimilarSongsByArtist", func() {
- It("returns on first match", func() {
- Expect(ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)).To(Equal([]Song{{
- Name: "Artist Similar Song",
- MBID: "mbid777",
- }}))
- Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
- })
- It("skips the agent if it returns an error", func() {
- mock.Err = errors.New("error")
- _, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
- Expect(err).To(MatchError(ErrNotFound))
- Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
- })
- It("interrupts if the context is canceled", func() {
- cancel()
- _, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
- Expect(err).To(MatchError(ErrNotFound))
- Expect(mock.Args).To(BeEmpty())
- })
- })
- })
-
- 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 []interface{}
+ Err error
}
func (a *mockAgent) AgentName() string {
@@ -570,7 +308,7 @@ func (a *mockAgent) AgentName() string {
}
func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
- a.Args = []any{id, name}
+ a.Args = []interface{}{id, name}
if a.Err != nil {
return "", a.Err
}
@@ -578,7 +316,7 @@ func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (st
}
func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
- a.Args = []any{id, name, mbid}
+ a.Args = []interface{}{id, name, mbid}
if a.Err != nil {
return "", a.Err
}
@@ -586,8 +324,7 @@ 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++
+ a.Args = []interface{}{id, name, mbid}
if a.Err != nil {
return "", a.Err
}
@@ -595,7 +332,7 @@ func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string)
}
func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
- a.Args = []any{id, name, mbid}
+ a.Args = []interface{}{id, name, mbid}
if a.Err != nil {
return nil, a.Err
}
@@ -606,7 +343,7 @@ func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([
}
func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) {
- a.Args = []any{id, name, mbid, limit}
+ a.Args = []interface{}{id, name, mbid, limit}
if a.Err != nil {
return nil, a.Err
}
@@ -617,7 +354,7 @@ func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string,
}
func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) {
- a.Args = []any{id, artistName, mbid, count}
+ a.Args = []interface{}{id, artistName, mbid, count}
if a.Err != nil {
return nil, a.Err
}
@@ -628,7 +365,7 @@ func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid st
}
func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
- a.Args = []any{name, artist, mbid}
+ a.Args = []interface{}{name, artist, mbid}
if a.Err != nil {
return nil, a.Err
}
@@ -640,39 +377,6 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
}, nil
}
-func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
- a.Args = []any{id, name, artist, mbid, count}
- if a.Err != nil {
- return nil, a.Err
- }
- return []Song{{
- Name: "Similar Song",
- MBID: "mbid555",
- }}, nil
-}
-
-func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
- a.Args = []any{id, name, artist, mbid, count}
- if a.Err != nil {
- return nil, a.Err
- }
- return []Song{{
- Name: "Album Similar Song",
- MBID: "mbid666",
- }}, nil
-}
-
-func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) {
- a.Args = []any{id, name, mbid, count}
- if a.Err != nil {
- return nil, a.Err
- }
- return []Song{{
- Name: "Artist Similar Song",
- MBID: "mbid777",
- }}, nil
-}
-
type emptyAgent struct {
Interface
}
@@ -685,26 +389,12 @@ type testImageAgent struct {
Name string
Images []ExternalImage
Err error
- Args []any
+ Args []interface{}
}
func (t *testImageAgent) AgentName() string { return t.Name }
func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
- 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}
+ t.Args = []interface{}{id, name, mbid}
return t.Images, t.Err
}
diff --git a/core/agents/deezer/client.go b/core/agents/deezer/client.go
new file mode 100644
index 000000000..e75526d80
--- /dev/null
+++ b/core/agents/deezer/client.go
@@ -0,0 +1,83 @@
+package deezer
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+
+ "github.com/navidrome/navidrome/log"
+)
+
+const apiBaseURL = "https://api.deezer.com"
+
+var (
+ ErrNotFound = errors.New("deezer: not found")
+)
+
+type httpDoer interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+type client struct {
+ httpDoer httpDoer
+}
+
+func newClient(hc httpDoer) *client {
+ return &client{hc}
+}
+
+func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
+ params := url.Values{}
+ params.Add("q", name)
+ params.Add("limit", strconv.Itoa(limit))
+ req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
+ if err != nil {
+ return nil, err
+ }
+ req.URL.RawQuery = params.Encode()
+
+ var results SearchArtistResults
+ err = c.makeRequest(req, &results)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(results.Data) == 0 {
+ return nil, ErrNotFound
+ }
+ return results.Data, nil
+}
+
+func (c *client) makeRequest(req *http.Request, response interface{}) error {
+ log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
+ resp, err := c.httpDoer.Do(req)
+ if err != nil {
+ return err
+ }
+
+ defer resp.Body.Close()
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+
+ if resp.StatusCode != 200 {
+ return c.parseError(data)
+ }
+
+ return json.Unmarshal(data, response)
+}
+
+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)
+}
diff --git a/core/agents/deezer/client_test.go b/core/agents/deezer/client_test.go
new file mode 100644
index 000000000..5e47460d4
--- /dev/null
+++ b/core/agents/deezer/client_test.go
@@ -0,0 +1,68 @@
+package deezer
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "os"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("client", func() {
+ var httpClient *fakeHttpClient
+ var client *client
+
+ BeforeEach(func() {
+ httpClient = &fakeHttpClient{}
+ client = newClient(httpClient)
+ })
+
+ Describe("ArtistImages", func() {
+ It("returns artist images from a successful request", func() {
+ f, err := os.Open("tests/fixtures/deezer.search.artist.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
+
+ artists, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
+ Expect(err).To(BeNil())
+ Expect(artists).To(HaveLen(17))
+ Expect(artists[0].Name).To(Equal("Michael Jackson"))
+ Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
+ })
+
+ It("fails if artist was not found", func() {
+ httpClient.mock("https://api.deezer.com/search/artist", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
+ })
+
+ _, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
+ Expect(err).To(MatchError(ErrNotFound))
+ })
+ })
+})
+
+type fakeHttpClient struct {
+ responses map[string]*http.Response
+ lastRequest *http.Request
+}
+
+func (c *fakeHttpClient) mock(url string, response http.Response) {
+ if c.responses == nil {
+ c.responses = make(map[string]*http.Response)
+ }
+ c.responses[url] = &response
+}
+
+func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
+ c.lastRequest = req
+ u := req.URL
+ u.RawQuery = ""
+ if resp, ok := c.responses[u.String()]; ok {
+ return resp, nil
+ }
+ panic("URL not mocked: " + u.String())
+}
diff --git a/core/agents/deezer/deezer.go b/core/agents/deezer/deezer.go
new file mode 100644
index 000000000..8cabfbcfb
--- /dev/null
+++ b/core/agents/deezer/deezer.go
@@ -0,0 +1,97 @@
+package deezer
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+
+ "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/cache"
+)
+
+const deezerAgentName = "deezer"
+const deezerApiPictureXlSize = 1000
+const deezerApiPictureBigSize = 500
+const deezerApiPictureMediumSize = 250
+const deezerApiPictureSmallSize = 56
+const deezerArtistSearchLimit = 50
+
+type deezerAgent struct {
+ dataStore model.DataStore
+ client *client
+}
+
+func deezerConstructor(dataStore model.DataStore) agents.Interface {
+ agent := &deezerAgent{dataStore: dataStore}
+ httpClient := &http.Client{
+ Timeout: consts.DefaultHttpClientTimeOut,
+ }
+ cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
+ agent.client = newClient(cachedHttpClient)
+ return agent
+}
+
+func (s *deezerAgent) AgentName() string {
+ return deezerAgentName
+}
+
+func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
+ artist, err := s.searchArtist(ctx, name)
+ if err != nil {
+ if errors.Is(err, agents.ErrNotFound) {
+ log.Warn(ctx, "Artist not found in deezer", "artist", name)
+ } else {
+ log.Error(ctx, "Error calling deezer", "artist", name, err)
+ }
+ return nil, err
+ }
+
+ var res []agents.ExternalImage
+ possibleImages := []struct {
+ URL string
+ Size int
+ }{
+ {artist.PictureXl, deezerApiPictureXlSize},
+ {artist.PictureBig, deezerApiPictureBigSize},
+ {artist.PictureMedium, deezerApiPictureMediumSize},
+ {artist.PictureSmall, deezerApiPictureSmallSize},
+ }
+ for _, imgData := range possibleImages {
+ if imgData.URL != "" {
+ res = append(res, agents.ExternalImage{
+ URL: imgData.URL,
+ Size: imgData.Size,
+ })
+ }
+ }
+ return res, nil
+}
+
+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
+ }
+
+ // If the first one has the same name, that's the one
+ if !strings.EqualFold(artists[0].Name, name) {
+ return nil, agents.ErrNotFound
+ }
+ return &artists[0], err
+}
+
+func init() {
+ conf.AddHook(func() {
+ if conf.Server.Deezer.Enabled {
+ agents.Register(deezerAgentName, deezerConstructor)
+ }
+ })
+}
diff --git a/adapters/deezer/deezer_suite_test.go b/core/agents/deezer/deezer_suite_test.go
similarity index 100%
rename from adapters/deezer/deezer_suite_test.go
rename to core/agents/deezer/deezer_suite_test.go
diff --git a/core/agents/deezer/responses.go b/core/agents/deezer/responses.go
new file mode 100644
index 000000000..112fe28ec
--- /dev/null
+++ b/core/agents/deezer/responses.go
@@ -0,0 +1,31 @@
+package deezer
+
+type SearchArtistResults struct {
+ Data []Artist `json:"data"`
+ Total int `json:"total"`
+ Next string `json:"next"`
+}
+
+type Artist struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Link string `json:"link"`
+ Picture string `json:"picture"`
+ PictureSmall string `json:"picture_small"`
+ PictureMedium string `json:"picture_medium"`
+ PictureBig string `json:"picture_big"`
+ PictureXl string `json:"picture_xl"`
+ NbAlbum int `json:"nb_album"`
+ NbFan int `json:"nb_fan"`
+ Radio bool `json:"radio"`
+ Tracklist string `json:"tracklist"`
+ Type string `json:"type"`
+}
+
+type Error struct {
+ Error struct {
+ Type string `json:"type"`
+ Message string `json:"message"`
+ Code int `json:"code"`
+ } `json:"error"`
+}
diff --git a/adapters/deezer/responses_test.go b/core/agents/deezer/responses_test.go
similarity index 52%
rename from adapters/deezer/responses_test.go
rename to core/agents/deezer/responses_test.go
index 5a3fc7798..95a7f43f4 100644
--- a/adapters/deezer/responses_test.go
+++ b/core/agents/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())
@@ -35,35 +35,4 @@ var _ = Describe("Responses", func() {
Expect(errorResp.Error.Message).To(Equal("Missing parameters: q"))
})
})
-
- Describe("Related Artists", func() {
- It("parses the related artists response correctly", func() {
- var resp RelatedArtists
- body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json")
- Expect(err).To(BeNil())
- err = json.Unmarshal(body, &resp)
- Expect(err).To(BeNil())
-
- Expect(resp.Data).To(HaveLen(20))
- justice := resp.Data[0]
- Expect(justice.Name).To(Equal("Justice"))
- Expect(justice.ID).To(Equal(6404))
- })
- })
-
- Describe("Top Tracks", func() {
- It("parses the top tracks response correctly", func() {
- var resp TopTracks
- body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json")
- Expect(err).To(BeNil())
- err = json.Unmarshal(body, &resp)
- Expect(err).To(BeNil())
-
- Expect(resp.Data).To(HaveLen(5))
- track := resp.Data[0]
- Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
- Expect(track.ID).To(Equal(67238732))
- Expect(track.Album.Title).To(Equal("Random Access Memories"))
- })
- })
})
diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go
index 9225a0442..e60c61909 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"
)
@@ -26,7 +22,6 @@ type AlbumInfo struct {
}
type Artist struct {
- ID string
Name string
MBID string
}
@@ -37,67 +32,13 @@ 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
+ Name string
+ MBID string
}
-// 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 {
@@ -133,41 +74,6 @@ type ArtistTopSongsRetriever interface {
GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
}
-// SimilarSongsByTrackRetriever provides similar songs based on a specific track
-type SimilarSongsByTrackRetriever interface {
- // GetSimilarSongsByTrack returns songs similar to the given track.
- // Parameters:
- // - id: local mediafile ID
- // - name: track title
- // - artist: artist name
- // - mbid: MusicBrainz recording ID (may be empty)
- // - count: maximum number of results
- GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
-}
-
-// SimilarSongsByAlbumRetriever provides similar songs based on an album
-type SimilarSongsByAlbumRetriever interface {
- // GetSimilarSongsByAlbum returns songs similar to tracks on the given album.
- // Parameters:
- // - id: local album ID
- // - name: album name
- // - artist: album artist name
- // - mbid: MusicBrainz release ID (may be empty)
- // - count: maximum number of results
- GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
-}
-
-// SimilarSongsByArtistRetriever provides similar songs based on an artist
-type SimilarSongsByArtistRetriever interface {
- // GetSimilarSongsByArtist returns songs similar to the artist's catalog.
- // Parameters:
- // - id: local artist ID
- // - name: artist name
- // - mbid: MusicBrainz artist ID (may be empty)
- // - count: maximum number of results
- GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error)
-}
-
var Map map[string]Constructor
func Register(name string, init Constructor) {
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/adapters/lastfm/agent.go b/core/agents/lastfm/agent.go
similarity index 58%
rename from adapters/lastfm/agent.go
rename to core/agents/lastfm/agent.go
index dbd73c30d..d01b496ec 100644
--- a/adapters/lastfm/agent.go
+++ b/core/agents/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"
)
@@ -27,25 +26,18 @@ const (
sessionKeyProperty = "LastFMSessionKey"
)
-var ignoredContent = []string{
- // Empty Artist/Album
+var ignoredBiographies = []string{
+ // Unknown Artist
`Read more on Last\.fm\.?`)
-
-func cleanContent(content string) string {
- return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, ""))
-}
-
type lastfmAgent struct {
ds model.DataStore
sessionKeys *agents.SessionKeys
apiKey string
secret string
- languages []string
+ lang string
client *client
- httpClient httpDoer
getInfoMutex sync.Mutex
}
@@ -55,15 +47,16 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
}
l := &lastfmAgent{
ds: ds,
- languages: conf.Server.LastFM.Languages,
+ lang: conf.Server.LastFM.Language,
apiKey: conf.Server.LastFM.ApiKey,
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)
+ l.client = newClient(l.apiKey, l.secret, l.lang, chc)
return l
}
@@ -73,47 +66,22 @@ func (l *lastfmAgent) AgentName() string {
var imageRegex = regexp.MustCompile(`u\/(\d+)`)
-// isValidContent checks if content is non-empty and not in the ignored list
-func isValidContent(content string) bool {
- content = strings.TrimSpace(content)
- if content == "" {
- return false
- }
- for _, ign := range ignoredContent {
- if strings.HasPrefix(content, ign) {
- return false
- }
- }
- return true
-}
-
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
- var a *Album
- var resp agents.AlbumInfo
- for _, lang := range l.languages {
- var err error
- a, err = l.callAlbumGetInfo(ctx, name, artist, lang)
- if err != nil {
- return nil, err
- }
- resp.Name = a.Name
- resp.MBID = a.MBID
- resp.URL = a.URL
- if isValidContent(a.Description.Summary) {
- resp.Description = cleanContent(a.Description.Summary)
- return &resp, nil
- }
- log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang)
+ a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
+ if err != nil {
+ return nil, err
}
- // This condition should not be hit (languages default to ["en"]), but just in case
- if a == nil {
- return nil, agents.ErrNotFound
- }
- return &resp, nil
+
+ return &agents.AlbumInfo{
+ Name: a.Name,
+ MBID: a.MBID,
+ Description: a.Description.Summary,
+ URL: a.URL,
+ }, nil
}
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)
if err != nil {
return nil, err
}
@@ -148,7 +116,7 @@ func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid str
}
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
- a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
+ a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return "", err
}
@@ -159,7 +127,7 @@ func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string)
}
func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
- a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
+ a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return "", err
}
@@ -170,17 +138,20 @@ func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (
}
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
- for _, lang := range l.languages {
- a, err := l.callArtistGetInfo(ctx, name, lang)
- if err != nil {
- return "", err
- }
- if isValidContent(a.Bio.Summary) {
- return cleanContent(a.Bio.Summary), nil
- }
- log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
+ a, err := l.callArtistGetInfo(ctx, name)
+ if err != nil {
+ return "", err
}
- return "", agents.ErrNotFound
+ a.Bio.Summary = strings.TrimSpace(a.Bio.Summary)
+ if a.Bio.Summary == "" {
+ return "", agents.ErrNotFound
+ }
+ for _, ign := range ignoredBiographies {
+ if strings.HasPrefix(a.Bio.Summary, ign) {
+ return "", nil
+ }
+ }
+ return a.Bio.Summary, nil
}
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
@@ -219,37 +190,14 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
-func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
- resp, err := l.callTrackGetSimilar(ctx, name, artist, count)
- if err != nil {
- return nil, err
- }
- if len(resp) == 0 {
- return nil, agents.ErrNotFound
- }
- 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}},
- })
- }
- return res, nil
-}
-
-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")
-)
+var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
- a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
+ hc := http.Client{
+ Timeout: consts.DefaultHttpClientTimeOut,
+ }
+ a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
}
@@ -257,7 +205,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
if err != nil {
return nil, fmt.Errorf("create artist image request: %w", err)
}
- resp, err := l.httpClient.Do(req)
+ resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("get artist url: %w", err)
}
@@ -271,55 +219,46 @@ 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" {
- continue
- }
- if strings.Contains(attr.Val, artistIgnoredImage) {
- log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val)
- return res, nil
- }
-
- res = []agents.ExternalImage{
- {URL: attr.Val},
+ if attr.Key == "content" {
+ res = []agents.ExternalImage{
+ {URL: attr.Val},
+ }
+ break
}
}
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) (*Album, error) {
+ a, err := l.client.albumGetInfo(ctx, name, artist, mbid)
+ 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, "")
+ }
+
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
}
-func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
+func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) {
l.getInfoMutex.Lock()
defer l.getInfoMutex.Unlock()
- a, err := l.client.artistGetInfo(ctx, name, lang)
+ a, err := l.client.artistGetInfo(ctx, name)
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
}
@@ -344,20 +283,11 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
-func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist string, count int) ([]SimilarTrack, error) {
- s, err := l.client.trackGetSimilar(ctx, name, artist, count)
- if err != nil {
- log.Error(ctx, "Error calling LastFM/track.getSimilar", "track", name, "artist", artist, err)
- return nil, err
+func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
+ if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
+ return track.Participants[model.RoleArtist][0].Name
}
- return s.Track, nil
-}
-
-func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
- if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
- return track.Participants[role][0].Name
- }
- return displayName
+ return track.Artist
}
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
@@ -367,13 +297,13 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
- artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
+ artist: l.getArtistForScrobble(track),
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzRecordingID,
duration: int(track.Duration),
- albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
+ albumArtist: track.AlbumArtist,
})
if err != nil {
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
@@ -393,13 +323,13 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
- artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
+ artist: l.getArtistForScrobble(&s.MediaFile),
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzRecordingID,
duration: int(s.Duration),
- albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist),
+ albumArtist: s.AlbumArtist,
timestamp: s.TimeStamp,
})
if err == nil {
@@ -411,8 +341,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)
@@ -423,10 +352,6 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
return err == nil && sk != ""
}
-func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
- return nil
-}
-
func init() {
conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
diff --git a/core/agents/lastfm/agent_test.go b/core/agents/lastfm/agent_test.go
new file mode 100644
index 000000000..4476d592f
--- /dev/null
+++ b/core/agents/lastfm/agent_test.go
@@ -0,0 +1,396 @@
+package lastfm
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core/agents"
+ "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"
+)
+
+const (
+ lastfmError3 = `{"error":3,"message":"Invalid Method - No method with that name in this package","links":[]}`
+ lastfmError6 = `{"error":6,"message":"The artist you supplied could not be found","links":[]}`
+)
+
+var _ = Describe("lastfmAgent", func() {
+ var ds model.DataStore
+ var ctx context.Context
+ BeforeEach(func() {
+ ds = &tests.MockDataStore{}
+ ctx = context.Background()
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.LastFM.Enabled = true
+ conf.Server.LastFM.ApiKey = "123"
+ conf.Server.LastFM.Secret = "secret"
+ })
+ Describe("lastFMConstructor", func() {
+ When("Agent is properly configured", func() {
+ It("uses configured api key and language", func() {
+ conf.Server.LastFM.Language = "pt"
+ agent := lastFMConstructor(ds)
+ Expect(agent.apiKey).To(Equal("123"))
+ Expect(agent.secret).To(Equal("secret"))
+ Expect(agent.lang).To(Equal("pt"))
+ })
+ })
+ When("Agent is disabled", func() {
+ It("returns nil", func() {
+ conf.Server.LastFM.Enabled = false
+ Expect(lastFMConstructor(ds)).To(BeNil())
+ })
+ })
+ When("ApiKey is empty", func() {
+ It("returns nil", func() {
+ conf.Server.LastFM.ApiKey = ""
+ Expect(lastFMConstructor(ds)).To(BeNil())
+ })
+ })
+ When("Secret is empty", func() {
+ It("returns nil", func() {
+ conf.Server.LastFM.Secret = ""
+ Expect(lastFMConstructor(ds)).To(BeNil())
+ })
+ })
+ })
+
+ Describe("GetArtistBiography", func() {
+ var agent *lastfmAgent
+ var httpClient *tests.FakeHttpClient
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("API_KEY", "SECRET", "pt", httpClient)
+ agent = lastFMConstructor(ds)
+ agent.client = client
+ })
+
+ It("returns the biography", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. Read more on Last.fm"))
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
+ })
+
+ It("returns an error if Last.fm call fails", func() {
+ httpClient.Err = errors.New("error")
+ _, err := agent.GetArtistBiography(ctx, "123", "U2", "")
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
+ })
+
+ It("returns an error if Last.fm call returns an error", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
+ _, err := agent.GetArtistBiography(ctx, "123", "U2", "")
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
+ })
+ })
+
+ Describe("GetSimilarArtists", func() {
+ var agent *lastfmAgent
+ var httpClient *tests.FakeHttpClient
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("API_KEY", "SECRET", "pt", httpClient)
+ agent = lastFMConstructor(ds)
+ agent.client = client
+ })
+
+ It("returns similar artists", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ Expect(agent.GetSimilarArtists(ctx, "123", "U2", "", 2)).To(Equal([]agents.Artist{
+ {Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"},
+ {Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"},
+ }))
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
+ })
+
+ It("returns an error if Last.fm call fails", func() {
+ httpClient.Err = errors.New("error")
+ _, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
+ })
+
+ It("returns an error if Last.fm call returns an error", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
+ _, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
+ })
+ })
+
+ Describe("GetArtistTopSongs", func() {
+ var agent *lastfmAgent
+ var httpClient *tests.FakeHttpClient
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("API_KEY", "SECRET", "pt", httpClient)
+ agent = lastFMConstructor(ds)
+ agent.client = client
+ })
+
+ It("returns top songs", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)).To(Equal([]agents.Song{
+ {Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"},
+ {Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"},
+ }))
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
+ })
+
+ It("returns an error if Last.fm call fails", func() {
+ httpClient.Err = errors.New("error")
+ _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
+ })
+
+ It("returns an error if Last.fm call returns an error", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
+ _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
+ })
+ })
+
+ Describe("Scrobbling", func() {
+ var agent *lastfmAgent
+ var httpClient *tests.FakeHttpClient
+ var track *model.MediaFile
+ BeforeEach(func() {
+ _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("API_KEY", "SECRET", "en", httpClient)
+ agent = lastFMConstructor(ds)
+ agent.client = client
+ track = &model.MediaFile{
+ ID: "123",
+ Title: "Track Title",
+ Album: "Track Album",
+ Artist: "Track Artist",
+ AlbumArtist: "Track AlbumArtist",
+ TrackNumber: 1,
+ Duration: 180,
+ MbzRecordingID: "mbz-123",
+ Participants: map[model.Role]model.ParticipantList{
+ model.RoleArtist: []model.Participant{
+ {Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
+ {Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
+ },
+ },
+ }
+ })
+
+ Describe("NowPlaying", func() {
+ It("calls Last.fm with correct params", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
+
+ err := agent.NowPlaying(ctx, "user-1", track, 0)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
+ sentParams := httpClient.SavedRequest.URL.Query()
+ Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
+ Expect(sentParams.Get("sk")).To(Equal("SK-1"))
+ Expect(sentParams.Get("track")).To(Equal(track.Title))
+ Expect(sentParams.Get("album")).To(Equal(track.Album))
+ Expect(sentParams.Get("artist")).To(Equal(track.Artist))
+ Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
+ Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
+ Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
+ Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
+ })
+
+ It("returns ErrNotAuthorized if user is not linked", func() {
+ err := agent.NowPlaying(ctx, "user-2", track, 0)
+ Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
+ })
+ })
+
+ Describe("scrobble", func() {
+ It("calls Last.fm with correct params", func() {
+ ts := time.Now()
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
+
+ err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
+ sentParams := httpClient.SavedRequest.URL.Query()
+ Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
+ Expect(sentParams.Get("sk")).To(Equal("SK-1"))
+ Expect(sentParams.Get("track")).To(Equal(track.Title))
+ Expect(sentParams.Get("album")).To(Equal(track.Album))
+ Expect(sentParams.Get("artist")).To(Equal(track.Artist))
+ Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
+ Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
+ Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
+ Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
+ Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
+ })
+
+ When("ScrobbleFirstArtistOnly is true", func() {
+ BeforeEach(func() {
+ conf.Server.LastFM.ScrobbleFirstArtistOnly = true
+ })
+
+ It("uses only the first artist", func() {
+ ts := time.Now()
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
+
+ err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
+
+ Expect(err).ToNot(HaveOccurred())
+ sentParams := httpClient.SavedRequest.URL.Query()
+ Expect(sentParams.Get("artist")).To(Equal("First Artist"))
+ })
+ })
+
+ It("skips songs with less than 31 seconds", func() {
+ track.Duration = 29
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
+
+ err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest).To(BeNil())
+ })
+
+ It("returns ErrNotAuthorized if user is not linked", func() {
+ err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
+ Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
+ })
+
+ It("returns ErrRetryLater on error 11", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":11,"message":"Service Offline - This service is temporarily offline. Try again later."}`)),
+ StatusCode: 400,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
+ Expect(err).To(MatchError(scrobbler.ErrRetryLater))
+ })
+
+ It("returns ErrRetryLater on error 16", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":16,"message":"There was a temporary error processing your request. Please try again"}`)),
+ StatusCode: 400,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
+ Expect(err).To(MatchError(scrobbler.ErrRetryLater))
+ })
+
+ It("returns ErrRetryLater on http errors", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
+ StatusCode: 500,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
+ Expect(err).To(MatchError(scrobbler.ErrRetryLater))
+ })
+
+ It("returns ErrUnrecoverable on other errors", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":8,"message":"Operation failed - Something else went wrong"}`)),
+ StatusCode: 400,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
+ Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
+ })
+ })
+ })
+
+ Describe("GetAlbumInfo", func() {
+ var agent *lastfmAgent
+ var httpClient *tests.FakeHttpClient
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("API_KEY", "SECRET", "pt", httpClient)
+ agent = lastFMConstructor(ds)
+ agent.client = client
+ })
+
+ It("returns the biography", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
+ Name: "Believe",
+ MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
+ Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob Read more on Last.fm.",
+ URL: "https://www.last.fm/music/Cher/Believe",
+ }))
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62"))
+ })
+
+ It("returns empty images if no images are available", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+ Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{
+ Name: "The Definitive Less Damage And More Joy",
+ URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy",
+ }))
+ Expect(httpClient.RequestCount).To(Equal(1))
+ Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy"))
+ })
+
+ It("returns an error if Last.fm call fails", func() {
+ httpClient.Err = errors.New("error")
+ _, 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(Equal("mbid-1234"))
+ })
+
+ It("returns an error if Last.fm call returns an error", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
+ _, 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(Equal("mbid-1234"))
+ })
+
+ 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", "")
+ Expect(err).To(HaveOccurred())
+ Expect(httpClient.RequestCount).To(Equal(1))
+ })
+
+ 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())
+ })
+ })
+ })
+})
diff --git a/adapters/lastfm/auth_router.go b/core/agents/lastfm/auth_router.go
similarity index 80%
rename from adapters/lastfm/auth_router.go
rename to core/agents/lastfm/auth_router.go
index ec1bb9290..290caaad3 100644
--- a/adapters/lastfm/auth_router.go
+++ b/core/agents/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,8 +41,10 @@ func NewRouter(ds model.DataStore) *Router {
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
r.Handler = r.routes()
- hc := httpclient.New(consts.DefaultHttpClientTimeOut)
- r.client = newClient(r.apiKey, r.secret, hc)
+ hc := &http.Client{
+ Timeout: consts.DefaultHttpClientTimeOut,
+ }
+ r.client = newClient(r.apiKey, r.secret, "en", hc)
return r
}
@@ -64,7 +65,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
- resp := map[string]any{
+ resp := map[string]interface{}{
"apiKey": s.apiKey,
}
u, _ := request.UserFrom(r.Context())
@@ -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
@@ -122,7 +110,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
if err != nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec
+ _, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
return
}
@@ -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/client.go b/core/agents/lastfm/client.go
similarity index 75%
rename from adapters/lastfm/client.go
rename to core/agents/lastfm/client.go
index 7b2601cf9..6a24ac80a 100644
--- a/adapters/lastfm/client.go
+++ b/core/agents/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 {
@@ -39,29 +34,24 @@ type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
-func newClient(apiKey string, secret string, hc httpDoer) *client {
- return &client{apiKey, secret, hc}
+func newClient(apiKey string, secret string, lang string, hc httpDoer) *client {
+ return &client{apiKey, secret, lang, hc}
}
type client struct {
apiKey string
secret string
+ lang string
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) {
+func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) {
params := url.Values{}
params.Add("method", "album.getInfo")
params.Add("album", name)
params.Add("artist", artist)
params.Add("mbid", mbid)
- params.Add("lang", lang)
+ params.Add("lang", c.lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
@@ -69,11 +59,11 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
return &response.Album, nil
}
-func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
+func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
- params.Add("artist", escapePlus(name))
- params.Add("lang", lang)
+ params.Add("artist", name)
+ params.Add("lang", c.lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
@@ -84,7 +74,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 +86,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 {
@@ -105,19 +95,6 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
return &response.TopTracks, nil
}
-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("limit", strconv.Itoa(limit))
- response, err := c.makeRequest(ctx, http.MethodGet, params, false)
- if err != nil {
- return nil, err
- }
- return &response.SimilarTracks, nil
-}
-
func (c *client) GetToken(ctx context.Context) (string, error) {
params := url.Values{}
params.Add("method", "auth.getToken")
@@ -208,15 +185,8 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
c.sign(params)
}
- var req *http.Request
- if method == http.MethodPost {
- body := strings.NewReader(params.Encode())
- req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, body)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- } else {
- req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
- req.URL.RawQuery = params.Encode()
- }
+ req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
+ req.URL.RawQuery = params.Encode()
log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
@@ -236,11 +206,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/core/agents/lastfm/client_test.go b/core/agents/lastfm/client_test.go
new file mode 100644
index 000000000..85ec11506
--- /dev/null
+++ b/core/agents/lastfm/client_test.go
@@ -0,0 +1,173 @@
+package lastfm
+
+import (
+ "bytes"
+ "context"
+ "crypto/md5"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("client", func() {
+ var httpClient *tests.FakeHttpClient
+ var client *client
+
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client = newClient("API_KEY", "SECRET", "pt", httpClient)
+ })
+
+ Describe("albumGetInfo", func() {
+ It("returns an album on successful response", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+
+ album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234")
+ Expect(err).To(BeNil())
+ 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"))
+ })
+ })
+
+ Describe("artistGetInfo", func() {
+ It("returns an artist for a successful response", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+
+ artist, err := client.artistGetInfo(context.Background(), "U2")
+ Expect(err).To(BeNil())
+ Expect(artist.Name).To(Equal("U2"))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
+ })
+
+ It("fails if Last.fm returns an http status != 200", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
+ StatusCode: 500,
+ }
+
+ _, err := client.artistGetInfo(context.Background(), "U2")
+ Expect(err).To(MatchError("last.fm http status: (500)"))
+ })
+
+ It("fails if Last.fm returns an http status != 200", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)),
+ StatusCode: 400,
+ }
+
+ _, err := client.artistGetInfo(context.Background(), "U2")
+ Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
+ })
+
+ It("fails if Last.fm returns an error", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)),
+ StatusCode: 200,
+ }
+
+ _, err := client.artistGetInfo(context.Background(), "U2")
+ Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
+ })
+
+ It("fails if HttpClient.Do() returns error", func() {
+ httpClient.Err = errors.New("generic error")
+
+ _, err := client.artistGetInfo(context.Background(), "U2")
+ Expect(err).To(MatchError("generic error"))
+ })
+
+ It("fails if returned body is not a valid JSON", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`NOT_VALID_JSON`)),
+ StatusCode: 200,
+ }
+
+ _, err := client.artistGetInfo(context.Background(), "U2")
+ Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
+ })
+
+ })
+
+ Describe("artistGetSimilar", func() {
+ It("returns an artist for a successful response", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+
+ similar, err := client.artistGetSimilar(context.Background(), "U2", 2)
+ Expect(err).To(BeNil())
+ 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"))
+ })
+ })
+
+ Describe("artistGetTopTracks", func() {
+ It("returns top tracks for a successful response", func() {
+ f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
+ httpClient.Res = http.Response{Body: f, StatusCode: 200}
+
+ top, err := client.artistGetTopTracks(context.Background(), "U2", 2)
+ Expect(err).To(BeNil())
+ 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"))
+ })
+ })
+
+ Describe("GetToken", func() {
+ It("returns a token when the request is successful", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"token":"TOKEN"}`)),
+ StatusCode: 200,
+ }
+
+ Expect(client.GetToken(context.Background())).To(Equal("TOKEN"))
+ queryParams := httpClient.SavedRequest.URL.Query()
+ Expect(queryParams.Get("method")).To(Equal("auth.getToken"))
+ Expect(queryParams.Get("format")).To(Equal("json"))
+ Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
+ Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
+ })
+ })
+
+ Describe("getSession", func() {
+ It("returns a session key when the request is successful", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"SESSION_KEY","subscriber":0}}`)),
+ StatusCode: 200,
+ }
+
+ Expect(client.getSession(context.Background(), "TOKEN")).To(Equal("SESSION_KEY"))
+ queryParams := httpClient.SavedRequest.URL.Query()
+ Expect(queryParams.Get("method")).To(Equal("auth.getSession"))
+ Expect(queryParams.Get("format")).To(Equal("json"))
+ Expect(queryParams.Get("token")).To(Equal("TOKEN"))
+ Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
+ Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
+ })
+ })
+
+ Describe("sign", func() {
+ It("adds an api_sig param with the signature", func() {
+ params := url.Values{}
+ params.Add("d", "444")
+ params.Add("callback", "https://myserver.com")
+ params.Add("a", "111")
+ params.Add("format", "json")
+ params.Add("c", "333")
+ params.Add("b", "222")
+ client.sign(params)
+ Expect(params).To(HaveKey("api_sig"))
+ sig := params.Get("api_sig")
+ expected := fmt.Sprintf("%x", md5.Sum([]byte("a111b222c333d444SECRET")))
+ Expect(sig).To(Equal(expected))
+ })
+ })
+})
diff --git a/adapters/lastfm/lastfm_suite_test.go b/core/agents/lastfm/lastfm_suite_test.go
similarity index 100%
rename from adapters/lastfm/lastfm_suite_test.go
rename to core/agents/lastfm/lastfm_suite_test.go
diff --git a/adapters/lastfm/responses.go b/core/agents/lastfm/responses.go
similarity index 84%
rename from adapters/lastfm/responses.go
rename to core/agents/lastfm/responses.go
index 026741672..1ceebe767 100644
--- a/adapters/lastfm/responses.go
+++ b/core/agents/lastfm/responses.go
@@ -5,7 +5,6 @@ type Response struct {
SimilarArtists SimilarArtists `json:"similarartists"`
TopTracks TopTracks `json:"toptracks"`
Album Album `json:"album"`
- SimilarTracks SimilarTracks `json:"similartracks"`
Error int `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
@@ -60,28 +59,6 @@ type TopTracks struct {
Attr Attr `json:"@attr"`
}
-type SimilarTracks struct {
- Track []SimilarTrack `json:"track"`
- Attr SimilarAttr `json:"@attr"`
-}
-
-type SimilarTrack struct {
- Name string `json:"name"`
- MBID string `json:"mbid"`
- Match float64 `json:"match"`
- Artist SimilarTrackArtist `json:"artist"`
-}
-
-type SimilarTrackArtist struct {
- Name string `json:"name"`
- MBID string `json:"mbid"`
-}
-
-type SimilarAttr struct {
- Artist string `json:"artist"`
- Track string `json:"track"`
-}
-
type Session struct {
Name string `json:"name"`
Key string `json:"key"`
diff --git a/adapters/lastfm/responses_test.go b/core/agents/lastfm/responses_test.go
similarity index 100%
rename from adapters/lastfm/responses_test.go
rename to core/agents/lastfm/responses_test.go
diff --git a/adapters/lastfm/token_received.html b/core/agents/lastfm/token_received.html
similarity index 100%
rename from adapters/lastfm/token_received.html
rename to core/agents/lastfm/token_received.html
diff --git a/core/agents/listenbrainz/agent.go b/core/agents/listenbrainz/agent.go
new file mode 100644
index 000000000..769b0f5a6
--- /dev/null
+++ b/core/agents/listenbrainz/agent.go
@@ -0,0 +1,129 @@
+package listenbrainz
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/core/scrobbler"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/cache"
+ "github.com/navidrome/navidrome/utils/slice"
+)
+
+const (
+ listenBrainzAgentName = "listenbrainz"
+ sessionKeyProperty = "ListenBrainzSessionKey"
+)
+
+type listenBrainzAgent struct {
+ ds model.DataStore
+ sessionKeys *agents.SessionKeys
+ baseURL string
+ client *client
+}
+
+func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
+ l := &listenBrainzAgent{
+ ds: ds,
+ sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
+ baseURL: conf.Server.ListenBrainz.BaseURL,
+ }
+ hc := &http.Client{
+ Timeout: consts.DefaultHttpClientTimeOut,
+ }
+ chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
+ l.client = newClient(l.baseURL, chc)
+ return l
+}
+
+func (l *listenBrainzAgent) AgentName() string {
+ return listenBrainzAgentName
+}
+
+func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
+ artistMBIDs := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
+ return p.MbzArtistID
+ })
+ artistNames := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
+ return p.Name
+ })
+ li := listenInfo{
+ TrackMetadata: trackMetadata{
+ ArtistName: track.Artist,
+ TrackName: track.Title,
+ ReleaseName: track.Album,
+ AdditionalInfo: additionalInfo{
+ SubmissionClient: consts.AppName,
+ SubmissionClientVersion: consts.Version,
+ TrackNumber: track.TrackNumber,
+ ArtistNames: artistNames,
+ ArtistMBIDs: artistMBIDs,
+ RecordingMBID: track.MbzRecordingID,
+ ReleaseMBID: track.MbzAlbumID,
+ ReleaseGroupMBID: track.MbzReleaseGroupID,
+ DurationMs: int(track.Duration * 1000),
+ },
+ },
+ }
+ return li
+}
+
+func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
+ sk, err := l.sessionKeys.Get(ctx, userId)
+ if err != nil || sk == "" {
+ return errors.Join(err, scrobbler.ErrNotAuthorized)
+ }
+
+ li := l.formatListen(track)
+ err = l.client.updateNowPlaying(ctx, sk, li)
+ if err != nil {
+ log.Warn(ctx, "ListenBrainz updateNowPlaying returned error", "track", track.Title, err)
+ return errors.Join(err, scrobbler.ErrUnrecoverable)
+ }
+ return nil
+}
+
+func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
+ sk, err := l.sessionKeys.Get(ctx, userId)
+ if err != nil || sk == "" {
+ return errors.Join(err, scrobbler.ErrNotAuthorized)
+ }
+
+ li := l.formatListen(&s.MediaFile)
+ li.ListenedAt = int(s.TimeStamp.Unix())
+ err = l.client.scrobble(ctx, sk, li)
+
+ if err == nil {
+ return nil
+ }
+ var lbErr *listenBrainzError
+ isListenBrainzError := errors.As(err, &lbErr)
+ if !isListenBrainzError {
+ log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err)
+ return errors.Join(err, scrobbler.ErrRetryLater)
+ }
+ if lbErr.Code == 500 || lbErr.Code == 503 {
+ return errors.Join(err, scrobbler.ErrRetryLater)
+ }
+ return errors.Join(err, scrobbler.ErrUnrecoverable)
+}
+
+func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {
+ sk, err := l.sessionKeys.Get(ctx, userId)
+ return err == nil && sk != ""
+}
+
+func init() {
+ conf.AddHook(func() {
+ if conf.Server.ListenBrainz.Enabled {
+ scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
+ return listenBrainzConstructor(ds)
+ })
+ }
+ })
+}
diff --git a/core/agents/listenbrainz/agent_test.go b/core/agents/listenbrainz/agent_test.go
new file mode 100644
index 000000000..e99b442de
--- /dev/null
+++ b/core/agents/listenbrainz/agent_test.go
@@ -0,0 +1,165 @@
+package listenbrainz
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/navidrome/navidrome/consts"
+ "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"
+ . "github.com/onsi/gomega/gstruct"
+)
+
+var _ = Describe("listenBrainzAgent", func() {
+ var ds model.DataStore
+ var ctx context.Context
+ var agent *listenBrainzAgent
+ var httpClient *tests.FakeHttpClient
+ var track *model.MediaFile
+
+ BeforeEach(func() {
+ ds = &tests.MockDataStore{}
+ ctx = context.Background()
+ _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
+ httpClient = &tests.FakeHttpClient{}
+ agent = listenBrainzConstructor(ds)
+ agent.client = newClient("http://localhost:8080", httpClient)
+ track = &model.MediaFile{
+ ID: "123",
+ Title: "Track Title",
+ Album: "Track Album",
+ Artist: "Track Artist",
+ TrackNumber: 1,
+ MbzRecordingID: "mbz-123",
+ MbzAlbumID: "mbz-456",
+ MbzReleaseGroupID: "mbz-789",
+ Duration: 142.2,
+ Participants: map[model.Role]model.ParticipantList{
+ model.RoleArtist: []model.Participant{
+ {Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
+ {Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
+ },
+ },
+ }
+ })
+
+ Describe("formatListen", func() {
+ It("constructs the listenInfo properly", func() {
+ lr := agent.formatListen(track)
+ Expect(lr).To(MatchAllFields(Fields{
+ "ListenedAt": Equal(0),
+ "TrackMetadata": MatchAllFields(Fields{
+ "ArtistName": Equal(track.Artist),
+ "TrackName": Equal(track.Title),
+ "ReleaseName": Equal(track.Album),
+ "AdditionalInfo": MatchAllFields(Fields{
+ "SubmissionClient": Equal(consts.AppName),
+ "SubmissionClientVersion": Equal(consts.Version),
+ "TrackNumber": Equal(track.TrackNumber),
+ "RecordingMBID": Equal(track.MbzRecordingID),
+ "ReleaseMBID": Equal(track.MbzAlbumID),
+ "ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
+ "ArtistNames": ConsistOf("Artist 1", "Artist 2"),
+ "ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
+ "DurationMs": Equal(142200),
+ }),
+ }),
+ }))
+ })
+ })
+
+ Describe("NowPlaying", func() {
+ It("updates NowPlaying successfully", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
+
+ err := agent.NowPlaying(ctx, "user-1", track, 0)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("returns ErrNotAuthorized if user is not linked", func() {
+ err := agent.NowPlaying(ctx, "user-2", track, 0)
+ Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
+ })
+ })
+
+ Describe("Scrobble", func() {
+ var sc scrobbler.Scrobble
+
+ BeforeEach(func() {
+ sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
+ })
+
+ It("sends a Scrobble successfully", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("sets the Timestamp properly", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).ToNot(HaveOccurred())
+
+ decoder := json.NewDecoder(httpClient.SavedRequest.Body)
+ var lr listenBrainzRequestBody
+ err = decoder.Decode(&lr)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
+ })
+
+ It("returns ErrNotAuthorized if user is not linked", func() {
+ err := agent.Scrobble(ctx, "user-2", sc)
+ Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
+ })
+
+ It("returns ErrRetryLater on error 503", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
+ StatusCode: 503,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).To(MatchError(scrobbler.ErrRetryLater))
+ })
+
+ It("returns ErrRetryLater on error 500", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
+ StatusCode: 500,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).To(MatchError(scrobbler.ErrRetryLater))
+ })
+
+ It("returns ErrRetryLater on http errors", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
+ StatusCode: 500,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).To(MatchError(scrobbler.ErrRetryLater))
+ })
+
+ It("returns ErrUnrecoverable on other errors", func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
+ StatusCode: 400,
+ }
+
+ err := agent.Scrobble(ctx, "user-1", sc)
+ Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
+ })
+ })
+})
diff --git a/adapters/listenbrainz/auth_router.go b/core/agents/listenbrainz/auth_router.go
similarity index 93%
rename from adapters/listenbrainz/auth_router.go
rename to core/agents/listenbrainz/auth_router.go
index 1ff1a1495..2382aeb73 100644
--- a/adapters/listenbrainz/auth_router.go
+++ b/core/agents/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
}
@@ -59,7 +60,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
- resp := map[string]any{}
+ resp := map[string]interface{}{}
u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.Get(r.Context(), u.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
@@ -106,7 +107,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) {
return
}
- _ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"status": resp.Valid, "user": resp.UserName})
+ _ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName})
}
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
diff --git a/adapters/listenbrainz/auth_router_test.go b/core/agents/listenbrainz/auth_router_test.go
similarity index 97%
rename from adapters/listenbrainz/auth_router_test.go
rename to core/agents/listenbrainz/auth_router_test.go
index c3861799a..dc705dbc9 100644
--- a/adapters/listenbrainz/auth_router_test.go
+++ b/core/agents/listenbrainz/auth_router_test.go
@@ -37,7 +37,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
- var parsed map[string]any
+ var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(false))
})
@@ -47,7 +47,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
- var parsed map[string]any
+ var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
})
@@ -80,7 +80,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
r.link(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
- var parsed map[string]any
+ var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))
diff --git a/core/agents/listenbrainz/client.go b/core/agents/listenbrainz/client.go
new file mode 100644
index 000000000..168aad549
--- /dev/null
+++ b/core/agents/listenbrainz/client.go
@@ -0,0 +1,179 @@
+package listenbrainz
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "path"
+
+ "github.com/navidrome/navidrome/log"
+)
+
+type listenBrainzError struct {
+ Code int
+ Message string
+}
+
+func (e *listenBrainzError) Error() string {
+ return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
+}
+
+type httpDoer interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+func newClient(baseURL string, hc httpDoer) *client {
+ return &client{baseURL, hc}
+}
+
+type client struct {
+ baseURL string
+ hc httpDoer
+}
+
+type listenBrainzResponse struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Error string `json:"error"`
+ Status string `json:"status"`
+ Valid bool `json:"valid"`
+ UserName string `json:"user_name"`
+}
+
+type listenBrainzRequest struct {
+ ApiKey string
+ Body listenBrainzRequestBody
+}
+
+type listenBrainzRequestBody struct {
+ ListenType listenType `json:"listen_type,omitempty"`
+ Payload []listenInfo `json:"payload,omitempty"`
+}
+
+type listenType string
+
+const (
+ Single listenType = "single"
+ PlayingNow listenType = "playing_now"
+)
+
+type listenInfo struct {
+ ListenedAt int `json:"listened_at,omitempty"`
+ TrackMetadata trackMetadata `json:"track_metadata,omitempty"`
+}
+
+type trackMetadata struct {
+ ArtistName string `json:"artist_name,omitempty"`
+ TrackName string `json:"track_name,omitempty"`
+ ReleaseName string `json:"release_name,omitempty"`
+ AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
+}
+
+type additionalInfo struct {
+ SubmissionClient string `json:"submission_client,omitempty"`
+ SubmissionClientVersion string `json:"submission_client_version,omitempty"`
+ TrackNumber int `json:"tracknumber,omitempty"`
+ ArtistNames []string `json:"artist_names,omitempty"`
+ ArtistMBIDs []string `json:"artist_mbids,omitempty"`
+ RecordingMBID string `json:"recording_mbid,omitempty"`
+ ReleaseMBID string `json:"release_mbid,omitempty"`
+ ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
+ DurationMs int `json:"duration_ms,omitempty"`
+}
+
+func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
+ r := &listenBrainzRequest{
+ ApiKey: apiKey,
+ }
+ response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r)
+ if err != nil {
+ return nil, err
+ }
+ return response, nil
+}
+
+func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
+ r := &listenBrainzRequest{
+ ApiKey: apiKey,
+ Body: listenBrainzRequestBody{
+ ListenType: PlayingNow,
+ Payload: []listenInfo{li},
+ },
+ }
+
+ resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
+ if err != nil {
+ return err
+ }
+ if resp.Status != "ok" {
+ log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
+ }
+ return nil
+}
+
+func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
+ r := &listenBrainzRequest{
+ ApiKey: apiKey,
+ Body: listenBrainzRequestBody{
+ ListenType: Single,
+ Payload: []listenInfo{li},
+ },
+ }
+ resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
+ if err != nil {
+ return err
+ }
+ if resp.Status != "ok" {
+ log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
+ }
+ return nil
+}
+
+func (c *client) path(endpoint string) (string, error) {
+ u, err := url.Parse(c.baseURL)
+ if err != nil {
+ return "", err
+ }
+ u.Path = path.Join(u.Path, endpoint)
+ return u.String(), nil
+}
+
+func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
+ b, _ := json.Marshal(r.Body)
+ uri, err := c.path(endpoint)
+ if err != nil {
+ return nil, err
+ }
+ req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
+ req.Header.Add("Content-Type", "application/json; charset=UTF-8")
+
+ if r.ApiKey != "" {
+ req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
+ }
+
+ log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
+ resp, err := c.hc.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ defer resp.Body.Close()
+ decoder := json.NewDecoder(resp.Body)
+
+ var response listenBrainzResponse
+ jsonErr := decoder.Decode(&response)
+ if resp.StatusCode != 200 && jsonErr != nil {
+ return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
+ }
+ if jsonErr != nil {
+ return nil, jsonErr
+ }
+ if response.Code != 0 && response.Code != 200 {
+ return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
+ }
+
+ return &response, nil
+}
diff --git a/core/agents/listenbrainz/client_test.go b/core/agents/listenbrainz/client_test.go
new file mode 100644
index 000000000..680a7d185
--- /dev/null
+++ b/core/agents/listenbrainz/client_test.go
@@ -0,0 +1,120 @@
+package listenbrainz
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "os"
+
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("client", func() {
+ var httpClient *tests.FakeHttpClient
+ var client *client
+ BeforeEach(func() {
+ httpClient = &tests.FakeHttpClient{}
+ client = newClient("BASE_URL/", httpClient)
+ })
+
+ Describe("listenBrainzResponse", func() {
+ It("parses a response properly", func() {
+ var response listenBrainzResponse
+ err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response.Code).To(Equal(200))
+ Expect(response.Message).To(Equal("Message"))
+ Expect(response.UserName).To(Equal("UserName"))
+ Expect(response.Valid).To(BeTrue())
+ Expect(response.Status).To(Equal("ok"))
+ Expect(response.Error).To(Equal("Error"))
+ })
+ })
+
+ Describe("validateToken", func() {
+ BeforeEach(func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
+ StatusCode: 200,
+ }
+ })
+
+ It("formats the request properly", func() {
+ _, err := client.validateToken(context.Background(), "LB-TOKEN")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
+ Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+ })
+
+ It("parses and returns the response", func() {
+ res, err := client.validateToken(context.Background(), "LB-TOKEN")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(res.Valid).To(Equal(true))
+ Expect(res.UserName).To(Equal("ListenBrainzUser"))
+ })
+ })
+
+ Context("with listenInfo", func() {
+ var li listenInfo
+ BeforeEach(func() {
+ httpClient.Res = http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
+ StatusCode: 200,
+ }
+ li = listenInfo{
+ TrackMetadata: trackMetadata{
+ ArtistName: "Track Artist",
+ TrackName: "Track Title",
+ ReleaseName: "Track Album",
+ AdditionalInfo: additionalInfo{
+ TrackNumber: 1,
+ ArtistNames: []string{"Artist 1", "Artist 2"},
+ ArtistMBIDs: []string{"mbz-789", "mbz-012"},
+ RecordingMBID: "mbz-123",
+ ReleaseMBID: "mbz-456",
+ DurationMs: 142200,
+ },
+ },
+ }
+ })
+
+ Describe("updateNowPlaying", func() {
+ It("formats the request properly", func() {
+ Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
+ Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+
+ body, _ := io.ReadAll(httpClient.SavedRequest.Body)
+ f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
+ Expect(body).To(MatchJSON(f))
+ })
+ })
+
+ Describe("scrobble", func() {
+ BeforeEach(func() {
+ li.ListenedAt = 1635000000
+ })
+
+ It("formats the request properly", func() {
+ Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
+ Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
+ Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
+ Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
+ Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
+
+ body, _ := io.ReadAll(httpClient.SavedRequest.Body)
+ f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
+ Expect(body).To(MatchJSON(f))
+ })
+ })
+ })
+})
diff --git a/adapters/listenbrainz/listenbrainz_suite_test.go b/core/agents/listenbrainz/listenbrainz_suite_test.go
similarity index 100%
rename from adapters/listenbrainz/listenbrainz_suite_test.go
rename to core/agents/listenbrainz/listenbrainz_suite_test.go
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/agents/spotify/client.go b/core/agents/spotify/client.go
new file mode 100644
index 000000000..25b1f9ede
--- /dev/null
+++ b/core/agents/spotify/client.go
@@ -0,0 +1,116 @@
+package spotify
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/navidrome/navidrome/log"
+)
+
+const apiBaseUrl = "https://api.spotify.com/v1/"
+
+var (
+ ErrNotFound = errors.New("spotify: not found")
+)
+
+type httpDoer interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+func newClient(id, secret string, hc httpDoer) *client {
+ return &client{id, secret, hc}
+}
+
+type client struct {
+ id string
+ secret string
+ hc httpDoer
+}
+
+func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
+ token, err := c.authorize(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ params := url.Values{}
+ params.Add("type", "artist")
+ params.Add("q", name)
+ params.Add("offset", "0")
+ params.Add("limit", strconv.Itoa(limit))
+ req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"search", nil)
+ req.URL.RawQuery = params.Encode()
+ req.Header.Add("Authorization", "Bearer "+token)
+
+ var results SearchResults
+ err = c.makeRequest(req, &results)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(results.Artists.Items) == 0 {
+ return nil, ErrNotFound
+ }
+ return results.Artists.Items, err
+}
+
+func (c *client) authorize(ctx context.Context) (string, error) {
+ payload := url.Values{}
+ payload.Add("grant_type", "client_credentials")
+
+ encodePayload := payload.Encode()
+ req, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload))
+ req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload)))
+ auth := c.id + ":" + c.secret
+ req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
+
+ response := map[string]interface{}{}
+ err := c.makeRequest(req, &response)
+ if err != nil {
+ return "", err
+ }
+
+ if v, ok := response["access_token"]; ok {
+ return v.(string), nil
+ }
+ log.Error(ctx, "Invalid spotify response", "resp", response)
+ return "", errors.New("invalid response")
+}
+
+func (c *client) makeRequest(req *http.Request, response interface{}) error {
+ log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
+ resp, err := c.hc.Do(req)
+ if err != nil {
+ return err
+ }
+
+ defer resp.Body.Close()
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+
+ if resp.StatusCode != 200 {
+ return c.parseError(data)
+ }
+
+ return json.Unmarshal(data, response)
+}
+
+func (c *client) parseError(data []byte) error {
+ var e Error
+ err := json.Unmarshal(data, &e)
+ if err != nil {
+ return err
+ }
+ return fmt.Errorf("spotify error(%s): %s", e.Code, e.Message)
+}
diff --git a/core/agents/spotify/client_test.go b/core/agents/spotify/client_test.go
new file mode 100644
index 000000000..2782d2122
--- /dev/null
+++ b/core/agents/spotify/client_test.go
@@ -0,0 +1,131 @@
+package spotify
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "os"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("client", func() {
+ var httpClient *fakeHttpClient
+ var client *client
+
+ BeforeEach(func() {
+ httpClient = &fakeHttpClient{}
+ client = newClient("SPOTIFY_ID", "SPOTIFY_SECRET", httpClient)
+ })
+
+ Describe("ArtistImages", func() {
+ It("returns artist images from a successful request", func() {
+ f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
+ httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
+ httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
+ })
+
+ artists, err := client.searchArtists(context.TODO(), "U2", 10)
+ Expect(err).To(BeNil())
+ Expect(artists).To(HaveLen(20))
+ Expect(artists[0].Popularity).To(Equal(82))
+
+ images := artists[0].Images
+ Expect(images).To(HaveLen(3))
+ Expect(images[0].Width).To(Equal(640))
+ Expect(images[1].Width).To(Equal(320))
+ Expect(images[2].Width).To(Equal(160))
+ })
+
+ It("fails if artist was not found", func() {
+ httpClient.mock("https://api.spotify.com/v1/search", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{
+ "artists" : {
+ "href" : "https://api.spotify.com/v1/search?query=dasdasdas%2Cdna&type=artist&offset=0&limit=20",
+ "items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0
+ }}`)),
+ })
+ httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
+ })
+
+ _, err := client.searchArtists(context.TODO(), "U2", 10)
+ Expect(err).To(MatchError(ErrNotFound))
+ })
+
+ It("fails if not able to authorize", func() {
+ f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
+ httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
+ httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
+ StatusCode: 400,
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
+ })
+
+ _, err := client.searchArtists(context.TODO(), "U2", 10)
+ Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
+ })
+ })
+
+ Describe("authorize", func() {
+ It("returns an access_token on successful authorization", func() {
+ httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
+ })
+
+ token, err := client.authorize(context.TODO())
+ Expect(err).To(BeNil())
+ Expect(token).To(Equal("NEW_ACCESS_TOKEN"))
+ auth := httpClient.lastRequest.Header.Get("Authorization")
+ Expect(auth).To(Equal("Basic U1BPVElGWV9JRDpTUE9USUZZX1NFQ1JFVA=="))
+ })
+
+ It("fails on unsuccessful authorization", func() {
+ httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
+ StatusCode: 400,
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
+ })
+
+ _, err := client.authorize(context.TODO())
+ Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
+ })
+
+ It("fails on invalid JSON response", func() {
+ httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)),
+ })
+
+ _, err := client.authorize(context.TODO())
+ Expect(err).To(MatchError("invalid character 'N' looking for beginning of object key string"))
+ })
+ })
+})
+
+type fakeHttpClient struct {
+ responses map[string]*http.Response
+ lastRequest *http.Request
+}
+
+func (c *fakeHttpClient) mock(url string, response http.Response) {
+ if c.responses == nil {
+ c.responses = make(map[string]*http.Response)
+ }
+ c.responses[url] = &response
+}
+
+func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
+ c.lastRequest = req
+ u := req.URL
+ u.RawQuery = ""
+ if resp, ok := c.responses[u.String()]; ok {
+ return resp, nil
+ }
+ panic("URL not mocked: " + u.String())
+}
diff --git a/core/agents/spotify/responses.go b/core/agents/spotify/responses.go
new file mode 100644
index 000000000..21166bf74
--- /dev/null
+++ b/core/agents/spotify/responses.go
@@ -0,0 +1,30 @@
+package spotify
+
+type SearchResults struct {
+ Artists ArtistsResult `json:"artists"`
+}
+
+type ArtistsResult struct {
+ HRef string `json:"href"`
+ Items []Artist `json:"items"`
+}
+
+type Artist struct {
+ Genres []string `json:"genres"`
+ HRef string `json:"href"`
+ ID string `json:"id"`
+ Popularity int `json:"popularity"`
+ Images []Image `json:"images"`
+ Name string `json:"name"`
+}
+
+type Image struct {
+ URL string `json:"url"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+}
+
+type Error struct {
+ Code string `json:"error"`
+ Message string `json:"error_description"`
+}
diff --git a/core/agents/spotify/responses_test.go b/core/agents/spotify/responses_test.go
new file mode 100644
index 000000000..704119816
--- /dev/null
+++ b/core/agents/spotify/responses_test.go
@@ -0,0 +1,48 @@
+package spotify
+
+import (
+ "encoding/json"
+ "os"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Responses", func() {
+ Describe("Search type=artist", func() {
+ It("parses the artist search result correctly ", func() {
+ var resp SearchResults
+ body, _ := os.ReadFile("tests/fixtures/spotify.search.artist.json")
+ err := json.Unmarshal(body, &resp)
+ Expect(err).To(BeNil())
+
+ Expect(resp.Artists.Items).To(HaveLen(20))
+ u2 := resp.Artists.Items[0]
+ Expect(u2.Name).To(Equal("U2"))
+ Expect(u2.Genres).To(ContainElements("irish rock", "permanent wave", "rock"))
+ Expect(u2.ID).To(Equal("51Blml2LZPmy7TTiAg47vQ"))
+ Expect(u2.HRef).To(Equal("https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ"))
+ Expect(u2.Images[0].URL).To(Equal("https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d"))
+ Expect(u2.Images[0].Width).To(Equal(640))
+ Expect(u2.Images[0].Height).To(Equal(640))
+ Expect(u2.Images[1].URL).To(Equal("https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d"))
+ Expect(u2.Images[1].Width).To(Equal(320))
+ Expect(u2.Images[1].Height).To(Equal(320))
+ Expect(u2.Images[2].URL).To(Equal("https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534"))
+ Expect(u2.Images[2].Width).To(Equal(160))
+ Expect(u2.Images[2].Height).To(Equal(160))
+ })
+ })
+
+ Describe("Error", func() {
+ It("parses the error response correctly", func() {
+ var errorResp Error
+ body := []byte(`{"error":"invalid_client","error_description":"Invalid client"}`)
+ err := json.Unmarshal(body, &errorResp)
+ Expect(err).To(BeNil())
+
+ Expect(errorResp.Code).To(Equal("invalid_client"))
+ Expect(errorResp.Message).To(Equal("Invalid client"))
+ })
+ })
+})
diff --git a/core/agents/spotify/spotify.go b/core/agents/spotify/spotify.go
new file mode 100644
index 000000000..633c32984
--- /dev/null
+++ b/core/agents/spotify/spotify.go
@@ -0,0 +1,96 @@
+package spotify
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sort"
+ "strings"
+
+ "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/cache"
+ "github.com/xrash/smetrics"
+)
+
+const spotifyAgentName = "spotify"
+
+type spotifyAgent struct {
+ ds model.DataStore
+ id string
+ secret string
+ client *client
+}
+
+func spotifyConstructor(ds model.DataStore) agents.Interface {
+ if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" {
+ return nil
+ }
+ l := &spotifyAgent{
+ ds: ds,
+ id: conf.Server.Spotify.ID,
+ secret: conf.Server.Spotify.Secret,
+ }
+ hc := &http.Client{
+ Timeout: consts.DefaultHttpClientTimeOut,
+ }
+ chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
+ l.client = newClient(l.id, l.secret, chc)
+ return l
+}
+
+func (s *spotifyAgent) AgentName() string {
+ return spotifyAgentName
+}
+
+func (s *spotifyAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
+ a, err := s.searchArtist(ctx, name)
+ if err != nil {
+ if errors.Is(err, model.ErrNotFound) {
+ log.Warn(ctx, "Artist not found in Spotify", "artist", name)
+ } else {
+ log.Error(ctx, "Error calling Spotify", "artist", name, err)
+ }
+ return nil, err
+ }
+
+ var res []agents.ExternalImage
+ for _, img := range a.Images {
+ res = append(res, agents.ExternalImage{
+ URL: img.URL,
+ Size: img.Width,
+ })
+ }
+ return res, nil
+}
+
+func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
+ artists, err := s.client.searchArtists(ctx, name, 40)
+ if err != nil || len(artists) == 0 {
+ return nil, model.ErrNotFound
+ }
+ name = strings.ToLower(name)
+
+ // Sort results, prioritizing artists with images, with similar names and with high popularity, in this order
+ sort.Slice(artists, func(i, j int) bool {
+ ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity)
+ aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity)
+ return ai < aj
+ })
+
+ // If the first one has the same name, that's the one
+ if strings.ToLower(artists[0].Name) != name {
+ return nil, model.ErrNotFound
+ }
+ return &artists[0], err
+}
+
+func init() {
+ conf.AddHook(func() {
+ agents.Register(spotifyAgentName, spotifyConstructor)
+ })
+}
diff --git a/core/matcher/matcher_suite_test.go b/core/agents/spotify/spotify_suite_test.go
similarity index 74%
rename from core/matcher/matcher_suite_test.go
rename to core/agents/spotify/spotify_suite_test.go
index 44877a3c8..275b05e73 100644
--- a/core/matcher/matcher_suite_test.go
+++ b/core/agents/spotify/spotify_suite_test.go
@@ -1,4 +1,4 @@
-package matcher_test
+package spotify
import (
"testing"
@@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
-func TestMatcher(t *testing.T) {
+func TestSpotify(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
- RunSpecs(t, "Matcher Suite")
+ RunSpecs(t, "Spotify Test Suite")
}
diff --git a/core/archiver.go b/core/archiver.go
index 60eb44858..63459816e 100644
--- a/core/archiver.go
+++ b/core/archiver.go
@@ -2,99 +2,65 @@ 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 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 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 +78,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,54 +86,46 @@ 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", sanitizeName(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 {
- plsName := str.SanitizeFilename(name)
+ plsName := sanitizeName(name)
w, err := z.CreateHeader(&zip.FileHeader{
Name: plsName + ".m3u",
Modified: mfs[0].UpdatedAt,
@@ -251,39 +155,15 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
if format != "" && format != "raw" {
ext = format
}
- return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
+ return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(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 sanitizeName(target string) string {
+ return strings.ReplaceAll(target, "/", "_")
}
-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 +174,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.DoStream(ctx, &mf, format, bitrate, 0)
+ } 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 +199,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..37c4ef9ab 100644
--- a/core/archiver_test.go
+++ b/core/archiver_test.go
@@ -4,19 +4,12 @@ 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 +19,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,8 +43,8 @@ var _ = Describe("Archiver", func() {
Sort: "album",
}}).Return(mfs, nil)
- ds.On("MediaFile").Return(mfRepo)
- ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
+ ds.On("MediaFile", mock.Anything).Return(mfRepo)
+ ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@@ -68,23 +57,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,15 +68,12 @@ 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)
- ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
+ ds.On("MediaFile", mock.Anything).Return(mfRepo)
+ ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
@@ -117,166 +86,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 +103,13 @@ var _ = Describe("Archiver", func() {
Tracks: mfs,
}
- ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
+ sh.On("Load", mock.Anything, "1").Return(share, nil)
+ ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).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 +121,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,8 +135,8 @@ var _ = Describe("Archiver", func() {
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
- ds.On("Playlist").Return(plRepo)
- ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
+ ds.On("Playlist", mock.Anything).Return(plRepo)
+ ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
@@ -380,195 +162,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 +188,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 +197,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,35 +207,22 @@ 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)
}
type mockMediaStreamer struct {
mock.Mock
- stream.MediaStreamer
+ core.MediaStreamer
}
-func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
- args := m.Called(ctx, mf, req)
+func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
+ args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
if args.Error(1) != nil {
return nil, args.Error(1)
}
- 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}
+ return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
}
type mockShare struct {
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/animation.go b/core/artwork/animation.go
deleted file mode 100644
index 07f493eb4..000000000
--- a/core/artwork/animation.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package artwork
-
-import (
- "bytes"
- "encoding/binary"
-)
-
-// isAnimatedGIF checks for multiple image descriptor blocks (0x2C) in a GIF file.
-// Animated GIFs use GIF89a and contain multiple image blocks.
-func isAnimatedGIF(data []byte) bool {
- // GIF header: "GIF87a" or "GIF89a"
- if !bytes.HasPrefix(data, []byte("GIF")) {
- return false
- }
-
- // Skip header (6 bytes) + logical screen descriptor (7 bytes)
- pos := 13
- if pos >= len(data) {
- return false
- }
-
- // Skip Global Color Table if present (bit 7 of packed byte at offset 10)
- if len(data) > 10 && data[10]&0x80 != 0 {
- // GCT size = 3 * 2^(N+1) where N = bits 0-2 of packed byte
- gctSize := 3 * (1 << ((data[10] & 0x07) + 1))
- pos += gctSize
- }
-
- frameCount := 0
- for pos < len(data) {
- switch data[pos] {
- case 0x2C: // Image Descriptor - marks a frame
- frameCount++
- if frameCount > 1 {
- return true
- }
- pos++ // skip introducer
- if pos+8 >= len(data) {
- return false
- }
- pos += 8 // skip x, y, w, h (each 2 bytes)
- packed := data[pos]
- pos++ // skip packed byte
- // Skip Local Color Table if present
- if packed&0x80 != 0 {
- lctSize := 3 * (1 << ((packed & 0x07) + 1))
- pos += lctSize
- }
- // Skip LZW minimum code size
- pos++
- // Skip sub-blocks
- pos = skipGIFSubBlocks(data, pos)
- case 0x21: // Extension block
- pos++ // skip introducer
- if pos >= len(data) {
- return false
- }
- pos++ // skip extension label
- // Skip sub-blocks
- pos = skipGIFSubBlocks(data, pos)
- case 0x3B: // Trailer
- return false
- default:
- // Unknown block, bail
- return false
- }
- }
- return false
-}
-
-// skipGIFSubBlocks advances past a sequence of GIF sub-blocks (terminated by a zero-length block).
-func skipGIFSubBlocks(data []byte, pos int) int {
- for pos < len(data) {
- blockSize := int(data[pos])
- pos++ // skip size byte
- if blockSize == 0 {
- break
- }
- pos += blockSize
- }
- return pos
-}
-
-// isAnimatedWebP checks for ANMF (animation frame) chunks in a WebP RIFF container.
-func isAnimatedWebP(data []byte) bool {
- // WebP header: "RIFF" + 4 bytes size + "WEBP"
- if !bytes.HasPrefix(data, []byte("RIFF")) || len(data) < 12 {
- return false
- }
- if !bytes.Equal(data[8:12], []byte("WEBP")) {
- return false
- }
- // Scan for ANMF chunk identifier
- return bytes.Contains(data[12:], []byte("ANMF"))
-}
-
-// isAnimatedPNG checks for the acTL (animation control) chunk in a PNG file.
-// APNG files contain an acTL chunk that is not present in static PNGs.
-func isAnimatedPNG(data []byte) bool {
- // PNG signature: 8 bytes
- pngSig := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
- if !bytes.HasPrefix(data, pngSig) {
- return false
- }
-
- // Scan chunks for "acTL" (animation control)
- pos := uint64(8)
- dataLen := uint64(len(data))
- for pos+8 <= dataLen {
- chunkLen := uint64(binary.BigEndian.Uint32(data[pos : pos+4]))
- chunkType := string(data[pos+4 : pos+8])
-
- if chunkType == "acTL" {
- return true
- }
- // Move to next chunk: 4 (length) + 4 (type) + chunkLen (data) + 4 (CRC)
- pos += 12 + chunkLen
- }
- return false
-}
diff --git a/core/artwork/animation_test.go b/core/artwork/animation_test.go
deleted file mode 100644
index 9000a8511..000000000
--- a/core/artwork/animation_test.go
+++ /dev/null
@@ -1,161 +0,0 @@
-package artwork
-
-import (
- "bytes"
- "encoding/binary"
- "image"
- "image/color"
- "image/gif"
- "image/png"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("Animation detection", func() {
- Describe("isAnimatedGIF", func() {
- It("detects an animated GIF with multiple frames", func() {
- Expect(isAnimatedGIF(createAnimatedGIF(2))).To(BeTrue())
- })
-
- It("detects an animated GIF with many frames", func() {
- Expect(isAnimatedGIF(createAnimatedGIF(5))).To(BeTrue())
- })
-
- It("does not flag a static GIF (single frame)", func() {
- Expect(isAnimatedGIF(createAnimatedGIF(1))).To(BeFalse())
- })
-
- It("returns false for non-GIF data", func() {
- Expect(isAnimatedGIF(nil)).To(BeFalse())
- Expect(isAnimatedGIF([]byte{0xFF, 0xD8})).To(BeFalse())
- })
- })
-
- Describe("isAnimatedWebP", func() {
- It("detects an animated WebP with ANMF chunk", func() {
- Expect(isAnimatedWebP(createAnimatedWebPBytes())).To(BeTrue())
- })
-
- It("does not flag a static WebP (no ANMF chunk)", func() {
- Expect(isAnimatedWebP(createStaticWebPBytes())).To(BeFalse())
- })
-
- It("returns false for non-WebP data", func() {
- Expect(isAnimatedWebP(nil)).To(BeFalse())
- Expect(isAnimatedWebP([]byte{0xFF, 0xD8})).To(BeFalse())
- })
- })
-
- Describe("isAnimatedPNG", func() {
- It("detects an APNG with acTL chunk", func() {
- Expect(isAnimatedPNG(createAPNGBytes())).To(BeTrue())
- })
-
- It("does not flag a static PNG (no acTL chunk)", func() {
- Expect(isAnimatedPNG(createStaticPNGBytes())).To(BeFalse())
- })
-
- It("returns false for non-PNG data", func() {
- Expect(isAnimatedPNG(nil)).To(BeFalse())
- Expect(isAnimatedPNG([]byte{0xFF, 0xD8})).To(BeFalse())
- })
- })
-})
-
-// createAnimatedGIF creates a minimal animated GIF with the given number of frames.
-func createAnimatedGIF(frames int) []byte {
- g := &gif.GIF{
- LoopCount: 0,
- }
- for range frames {
- img := image.NewPaletted(image.Rect(0, 0, 2, 2), color.Palette{color.Black, color.White})
- g.Image = append(g.Image, img)
- g.Delay = append(g.Delay, 10)
- }
- var buf bytes.Buffer
- err := gif.EncodeAll(&buf, g)
- if err != nil {
- panic(err)
- }
- return buf.Bytes()
-}
-
-// writeUint32LE appends a little-endian uint32 to the buffer.
-func writeUint32LE(buf *bytes.Buffer, v uint32) {
- b := make([]byte, 4)
- binary.LittleEndian.PutUint32(b, v)
- buf.Write(b)
-}
-
-// writeUint32BE appends a big-endian uint32 to the buffer.
-func writeUint32BE(buf *bytes.Buffer, v uint32) {
- b := make([]byte, 4)
- binary.BigEndian.PutUint32(b, v)
- buf.Write(b)
-}
-
-// createAnimatedWebPBytes creates a minimal RIFF/WEBP container with an ANMF chunk.
-func createAnimatedWebPBytes() []byte {
- var buf bytes.Buffer
- buf.WriteString("RIFF")
- writeUint32LE(&buf, 100) // file size placeholder
- buf.WriteString("WEBP")
- // VP8X chunk (extended format, required for animation)
- buf.WriteString("VP8X")
- writeUint32LE(&buf, 10)
- buf.Write(make([]byte, 10))
- // ANIM chunk (animation parameters)
- buf.WriteString("ANIM")
- writeUint32LE(&buf, 6)
- buf.Write(make([]byte, 6))
- // ANMF chunk (animation frame)
- buf.WriteString("ANMF")
- writeUint32LE(&buf, 16)
- buf.Write(make([]byte, 16))
- return buf.Bytes()
-}
-
-// createStaticWebPBytes creates a minimal RIFF/WEBP container without ANMF chunks.
-func createStaticWebPBytes() []byte {
- var buf bytes.Buffer
- buf.WriteString("RIFF")
- writeUint32LE(&buf, 20) // file size
- buf.WriteString("WEBP")
- // VP8 chunk (simple lossy format)
- buf.WriteString("VP8 ")
- writeUint32LE(&buf, 4)
- buf.Write(make([]byte, 4))
- return buf.Bytes()
-}
-
-// createAPNGBytes creates a minimal PNG with an acTL chunk (making it APNG).
-func createAPNGBytes() []byte {
- // Start with a real PNG
- staticPNG := createStaticPNGBytes()
-
- // Insert an acTL chunk after the IHDR chunk.
- // PNG structure: signature (8) + IHDR chunk (4 len + 4 type + 13 data + 4 crc = 25)
- ihdrEnd := 8 + 25
- var buf bytes.Buffer
- buf.Write(staticPNG[:ihdrEnd])
- // Write acTL chunk: length=8, type="acTL", data=num_frames(4)+num_plays(4), CRC=4
- writeUint32BE(&buf, 8) // chunk data length
- buf.WriteString("acTL")
- writeUint32BE(&buf, 2) // num_frames
- writeUint32BE(&buf, 0) // num_plays (0 = infinite)
- writeUint32BE(&buf, 0) // CRC placeholder
- buf.Write(staticPNG[ihdrEnd:])
- return buf.Bytes()
-}
-
-// createStaticPNGBytes creates a minimal valid static PNG.
-func createStaticPNGBytes() []byte {
- img := image.NewRGBA(image.Rect(0, 0, 2, 2))
- var buf bytes.Buffer
- err := png.Encode(&buf, img)
- if err != nil {
- panic(err)
- }
- return buf.Bytes()
-}
diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go
index 2a9ebe771..2e92b24c8 100644
--- a/core/artwork/artwork.go
+++ b/core/artwork/artwork.go
@@ -1,439 +1,130 @@
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)
+ 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..cfb7850bd
--- /dev/null
+++ b/core/artwork/artwork_internal_test.go
@@ -0,0 +1,328 @@
+package artwork
+
+import (
+ "context"
+ "errors"
+ "image"
+ "image/jpeg"
+ "image/png"
+ "io"
+ "os"
+ "path/filepath"
+
+ "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 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{}
+ ds = &tests.MockDataStore{
+ MockedTranscoding: &tests.MockTranscodingRepo{},
+ MockedFolder: folderRepo,
+ }
+ 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"}}
+ alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
+ 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"),
+ )
+ })
+ })
+ Describe("artistArtworkReader", func() {
+ Context("Multiple covers", func() {
+ BeforeEach(func() {
+ folderRepo.result = []model.Folder{{
+ 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(path).To(Equal(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,
+ })
+ 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"))
+ })
+ })
+ })
+ 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 a 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 a 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(format string, landscape bool, size int) {
+ coverFileName := "cover." + format
+ dirName := createImage(format, landscape, size)
+ alCover = model.Album{
+ ID: "444",
+ Name: "Only external",
+ FolderIDs: []string{"tmp"},
+ }
+ folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
+ 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("png"))
+ Expect(img.Bounds().Size().X).To(Equal(size))
+ Expect(img.Bounds().Size().Y).To(Equal(size))
+ },
+ Entry("portrait png image", "png", false, 200),
+ Entry("landscape png image", "png", true, 200),
+ Entry("portrait jpg image", "jpg", false, 200),
+ Entry("landscape jpg image", "jpg", true, 200),
+ )
+ })
+ })
+})
+
+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..dfd66e5e5 100644
--- a/core/artwork/artwork_suite_test.go
+++ b/core/artwork/artwork_suite_test.go
@@ -1,129 +1,17 @@
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.
-type osDirFS struct{ fs.FS }
-
-func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil }
-
-// testFileScheme is the URL scheme registered to expose a tempdir as a
-// storage.MusicFS for artwork integration tests.
-const testFileScheme = "testfile"
-
-// testFileLibPath builds a `testfile://` library URL for the given absolute
-// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading
-// slash after ToSlash, which makes url.Parse treat the drive letter as a
-// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the
-// registered constructor below strips that leading slash back off.
-func testFileLibPath(absPath string) string {
- p := filepath.ToSlash(absPath)
- if !strings.HasPrefix(p, "/") {
- p = "/" + p
- }
- return testFileScheme + "://" + p
-}
-
-func init() {
- // Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by
- // integration tests that need real files but not the taglib extractor.
- storage.Register(testFileScheme, func(u url.URL) storage.Storage {
- root := u.Path
- // Undo the leading slash added by testFileLibPath on Windows so that
- // os.Stat / os.DirFS receive a native path like `C:\foo`.
- if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' {
- root = root[1:]
- }
- return &osDirStorage{root: filepath.FromSlash(root)}
- })
-}
-
-type osDirStorage struct{ root string }
-
-func (s *osDirStorage) FS() (storage.MusicFS, error) {
- if _, err := os.Stat(s.root); err != nil {
- return nil, err
- }
- 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_decode_test.go b/core/artwork/benchmark_decode_test.go
deleted file mode 100644
index cfbfe5605..000000000
--- a/core/artwork/benchmark_decode_test.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package artwork
-
-import (
- "bytes"
- "fmt"
- "image"
- _ "image/jpeg"
- _ "image/png"
- "testing"
-)
-
-func BenchmarkImageDecode(b *testing.B) {
- sizes := []int{300, 1000, 3000}
- formats := []struct {
- name string
- gen func(tb testing.TB, w, h int) []byte
- }{
- {"jpeg", func(tb testing.TB, w, h int) []byte { return generateJPEG(tb, w, h, 75) }},
- {"png", func(tb testing.TB, w, h int) []byte { return generatePNG(tb, w, h) }},
- }
-
- for _, format := range formats {
- for _, size := range sizes {
- data := format.gen(b, size, size)
- b.Run(fmt.Sprintf("%s/%dx%d", format.name, size, size), func(b *testing.B) {
- b.SetBytes(int64(len(data)))
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _, _, err := image.Decode(bytes.NewReader(data))
- if err != nil {
- b.Fatal(err)
- }
- }
- })
- }
- }
-}
diff --git a/core/artwork/benchmark_encode_test.go b/core/artwork/benchmark_encode_test.go
deleted file mode 100644
index d8ab858f5..000000000
--- a/core/artwork/benchmark_encode_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package artwork
-
-import (
- "bytes"
- "fmt"
- "image/jpeg"
- "image/png"
- "testing"
-)
-
-func BenchmarkImageEncode(b *testing.B) {
- img := generateGradientImage(300, 300)
-
- jpegQualities := []int{60, 75, 90}
- for _, q := range jpegQualities {
- b.Run(fmt.Sprintf("jpeg/q%d/300x300", q), func(b *testing.B) {
- var buf bytes.Buffer
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- buf.Reset()
- if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
- b.Fatal(err)
- }
- }
- b.ReportMetric(float64(buf.Len()), "bytes")
- })
- }
-
- b.Run("png/300x300", func(b *testing.B) {
- var buf bytes.Buffer
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- buf.Reset()
- if err := png.Encode(&buf, img); err != nil {
- b.Fatal(err)
- }
- }
- b.ReportMetric(float64(buf.Len()), "bytes")
- })
-}
diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go
deleted file mode 100644
index f3201d412..000000000
--- a/core/artwork/benchmark_helpers_test.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package artwork
-
-import (
- "bytes"
- "image"
- "image/color"
- "image/draw"
- "image/jpeg"
- "image/png"
- "testing"
-)
-
-// generateJPEG creates a JPEG image of the given dimensions with a gradient pattern.
-// The gradient ensures the image has realistic entropy (not trivially compressible).
-func generateJPEG(t testing.TB, width, height, quality int) []byte {
- t.Helper()
- img := generateGradientImage(width, height)
- var buf bytes.Buffer
- if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
- t.Fatal(err)
- }
- return buf.Bytes()
-}
-
-// generatePNG creates a PNG image of the given dimensions with a gradient pattern.
-func generatePNG(t testing.TB, width, height int) []byte {
- t.Helper()
- img := generateGradientImage(width, height)
- var buf bytes.Buffer
- if err := png.Encode(&buf, img); err != nil {
- t.Fatal(err)
- }
- return buf.Bytes()
-}
-
-// 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 {
- r := uint8((x * 255) / width)
- g := uint8((y * 255) / height)
- b := uint8(((x + y) * 255) / (width + height))
- img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255})
- }
- }
- 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/benchmark_pipeline_test.go b/core/artwork/benchmark_pipeline_test.go
deleted file mode 100644
index 23d5954df..000000000
--- a/core/artwork/benchmark_pipeline_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package artwork
-
-import (
- "fmt"
- "testing"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
-)
-
-func BenchmarkResizeFullPipeline(b *testing.B) {
- cleanup := configtest.SetupConfig()
- b.Cleanup(cleanup)
- conf.Server.CoverArtQuality = 75
-
- sourceSizes := []int{1000, 3000}
- targetSize := 300
-
- for _, srcSize := range sourceSizes {
- jpegData := generateJPEG(b, srcSize, srcSize, 90)
-
- b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d", srcSize, srcSize, targetSize), func(b *testing.B) {
- b.SetBytes(int64(len(jpegData)))
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- result, _, err := resizeStaticImage(jpegData, targetSize, false)
- if err != nil {
- b.Fatal(err)
- }
- if result == nil {
- b.Fatal("expected non-nil resized image")
- }
- }
- })
-
- b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d_square", srcSize, srcSize, targetSize), func(b *testing.B) {
- b.SetBytes(int64(len(jpegData)))
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- result, _, err := resizeStaticImage(jpegData, targetSize, true)
- if err != nil {
- b.Fatal(err)
- }
- if result == nil {
- b.Fatal("expected non-nil resized image")
- }
- }
- })
- }
-}
diff --git a/core/artwork/benchmark_tag_test.go b/core/artwork/benchmark_tag_test.go
deleted file mode 100644
index fd649beab..000000000
--- a/core/artwork/benchmark_tag_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package artwork
-
-import (
- "path/filepath"
- "runtime"
- "testing"
-
- "go.senan.xyz/taglib"
-)
-
-func BenchmarkTagExtraction(b *testing.B) {
- // Ensure working directory is the project root (tests.Init not called with -run='^$')
- _, file, _, ok := runtime.Caller(0)
- if !ok {
- b.Fatal("runtime.Caller failed")
- }
- appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
-
- // Use existing test fixture with embedded artwork
- testFile := filepath.Join(appPath, "tests/fixtures/artist/an-album/test.mp3")
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- f, err := taglib.OpenReadOnly(testFile, taglib.WithReadStyle(taglib.ReadStyleFast))
- if err != nil {
- b.Fatal(err)
- }
- images := f.Properties().Images
- if len(images) == 0 {
- b.Fatal("no images found in test file")
- }
- data, err := f.Image(0)
- if err != nil || len(data) == 0 {
- b.Fatal("failed to extract image data")
- }
- f.Close()
- }
-}
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..909d299d8
--- /dev/null
+++ b/core/artwork/cache_warmer.go
@@ -0,0 +1,163 @@
+package artwork
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "maps"
+ "slices"
+ "sync"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
+ "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 in the UICoverArtSize constant.
+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),
+ }
+
+ // 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{}
+}
+
+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, 2, 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()
+
+ r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
+ if err != nil {
+ return fmt.Errorf("caching id='%s': %w", id, err)
+ }
+ defer r.Close()
+ _, err = io.Copy(io.Discard, r)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+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..4125d6de0
--- /dev/null
+++ b/core/artwork/cache_warmer_test.go
@@ -0,0 +1,221 @@
+package artwork
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "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() {
+ 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 := 0; i < 5; i++ {
+ 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))
+ })
+ })
+})
+
+type mockArtwork struct {
+ err error
+}
+
+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
+ }
+ return io.NopCloser(strings.NewReader("test")), time.Now(), nil
+}
+
+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/disc_test.go b/core/artwork/disc_test.go
deleted file mode 100644
index 8852741cf..000000000
--- a/core/artwork/disc_test.go
+++ /dev/null
@@ -1,539 +0,0 @@
-package artwork
-
-import (
- "context"
- "os"
- "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"
-)
-
-var _ = Describe("Disc Artwork Reader", func() {
- Describe("extractDiscNumber", func() {
- DescribeTable("extracts disc number from filename based on glob pattern",
- func(pattern, filename string, expectedNum int, expectedOk bool) {
- num, ok := extractDiscNumber(pattern, filename)
- Expect(ok).To(Equal(expectedOk))
- if expectedOk {
- Expect(num).To(Equal(expectedNum))
- }
- },
- // Standard disc patterns
- Entry("disc1.jpg", "disc*.*", "disc1.jpg", 1, true),
- Entry("disc2.png", "disc*.*", "disc2.png", 2, true),
- Entry("disc01.jpg", "disc*.*", "disc01.jpg", 1, true),
- Entry("disc02.png", "disc*.*", "disc02.png", 2, true),
- Entry("disc10.jpg", "disc*.*", "disc10.jpg", 10, true),
-
- // CD patterns
- Entry("cd1.jpg", "cd*.*", "cd1.jpg", 1, true),
- Entry("cd02.png", "cd*.*", "cd02.png", 2, true),
-
- // No number in filename
- Entry("disc.jpg has no number", "disc*.*", "disc.jpg", 0, false),
- Entry("cd.jpg has no number", "cd*.*", "cd.jpg", 0, false),
-
- // Extra text after number
- Entry("disc2-bonus.jpg", "disc*.*", "disc2-bonus.jpg", 2, true),
- Entry("disc01_front.png", "disc*.*", "disc01_front.png", 1, true),
-
- // Case insensitive (filename already lowered by caller)
- Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true),
-
- // HasPrefix guard: filename doesn't share the pattern's literal prefix
- Entry("cover.jpg with disc*.* (no prefix match)", "disc*.*", "cover.jpg", 0, false),
-
- // Pattern with no wildcard before dot
- Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true),
-
- // '?' single-char wildcard
- Entry("disc?.jpg with disc1.jpg", "disc?.jpg", "disc1.jpg", 1, true),
- Entry("disc?.jpg with disc2.jpg", "disc?.jpg", "disc2.jpg", 2, true),
- Entry("cd??.jpg with cd07.jpg", "cd??.jpg", "cd07.jpg", 7, true),
-
- // '[...]' character class wildcard
- Entry("cd[12].jpg with cd1.jpg", "cd[12].jpg", "cd1.jpg", 1, true),
- Entry("cd[12].jpg with cd2.jpg", "cd[12].jpg", "cd2.jpg", 2, true),
- Entry("disc[0-9].jpg with disc5.jpg", "disc[0-9].jpg", "disc5.jpg", 5, true),
-
- // Literal pattern (no wildcard) returns false
- Entry("shellac.png literal", "shellac.png", "shellac.png", 0, false),
- )
- })
-
- Describe("fromExternalFile", func() {
- var (
- ctx context.Context
- tmpDir string
- )
-
- BeforeEach(func() {
- ctx = context.Background()
- tmpDir = GinkgoT().TempDir()
- })
-
- // createFile creates the file on disk and returns its library-relative forward-slash path.
- createFile := func(relPath string) string {
- fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
- Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
- Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
- return relPath
- }
-
- // removeFile removes a library-relative file from disk.
- removeFile := func(relPath string) {
- Expect(os.Remove(filepath.Join(tmpDir, filepath.FromSlash(relPath)))).To(Succeed())
- }
-
- It("matches file with disc number in single-folder album", func() {
- f1 := createFile("album/disc1.jpg")
- f2 := createFile("album/disc2.jpg")
- reader := &discArtworkReader{
- discNumber: 1,
- imgFiles: []string{f1, f2},
- discFoldersRel: map[string]bool{"album": true},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromExternalFile(ctx, "disc*.*")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f1))
- })
-
- It("matches file without number in single-folder album (shared disc art)", func() {
- f1 := createFile("album/cover.png")
- reader := &discArtworkReader{
- discNumber: 1,
- imgFiles: []string{f1},
- discFoldersRel: map[string]bool{"album": true},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromExternalFile(ctx, "cover.*")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f1))
- })
-
- It("returns shared disc art for every disc number in single-folder album", func() {
- f1 := createFile("album/shellac.png")
- makeReader := func(discNum int) *discArtworkReader {
- return &discArtworkReader{
- discNumber: discNum,
- imgFiles: []string{f1},
- discFoldersRel: map[string]bool{"album": true},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
- }
-
- for _, disc := range []int{1, 2, 5} {
- sf := makeReader(disc).fromExternalFile(ctx, "shellac.png")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred(), "disc %d", disc)
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f1), "disc %d", disc)
- }
- })
-
- It("numbered and unnumbered patterns both resolve against the same reader", func() {
- f1 := createFile("album/cover.png")
- f2 := createFile("album/disc1.jpg")
- f3 := createFile("album/disc2.jpg")
- reader := &discArtworkReader{
- discNumber: 2,
- imgFiles: []string{f1, f2, f3},
- discFoldersRel: map[string]bool{"album": true},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromExternalFile(ctx, "disc*.*")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f3))
-
- sf = reader.fromExternalFile(ctx, "cover.*")
- r, path, err = sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f1))
- })
-
- It("respects DiscArtPriority order when both numbered and unnumbered patterns match", func() {
- f1 := createFile("album/cover.png")
- f2 := createFile("album/disc1.jpg")
- reader := &discArtworkReader{
- discNumber: 1,
- imgFiles: []string{f1, f2},
- discFoldersRel: map[string]bool{"album": true},
- 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()
-
- 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()
- })
-
- DescribeTable("numbered match wins over shared fallback within a pattern",
- func(discNumber, expectedIdx int) {
- files := []string{
- createFile("album/disc.jpg"),
- createFile("album/disc1.jpg"),
- createFile("album/disc2.jpg"),
- }
- reader := &discArtworkReader{
- discNumber: discNumber,
- imgFiles: files,
- discFoldersRel: map[string]bool{"album": true},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromExternalFile(ctx, "disc*.*")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(files[expectedIdx]))
- },
- Entry("disc 2 picks disc2.jpg over the shared disc.jpg", 2, 2),
- Entry("disc 3 falls back to disc.jpg when no numbered match exists", 3, 0),
- )
-
- It("tries the next fallback candidate when the first one cannot be opened", func() {
- f1 := createFile("album/cover.jpg")
- f2 := createFile("album/cover.png")
- // Remove f1 so Open will fail on it; f2 should still win.
- removeFile(f1)
- reader := &discArtworkReader{
- discNumber: 1,
- imgFiles: []string{f1, f2},
- discFoldersRel: map[string]bool{"album": true},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromExternalFile(ctx, "cover.*")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f2))
- })
-
- It("keeps scanning literal-pattern matches so fallback retry still works", func() {
- // Guards against an 'early break on first literal match' optimization.
- // Multiple imgFiles entries can share a basename (symlinks, case-variant
- // duplicates on case-sensitive filesystems). If the loop breaks after
- // recording just the first, the fallback retry cannot recover when
- // that first file is unreadable.
- f1 := createFile("album/stale/cover.png")
- f2 := createFile("album/cover.png")
- removeFile(f1)
- reader := &discArtworkReader{
- discNumber: 1,
- imgFiles: []string{f1, f2},
- discFoldersRel: map[string]bool{
- "album": true,
- "album/stale": true,
- },
- isMultiFolder: true,
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromExternalFile(ctx, "cover.png")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f2))
- })
-
- DescribeTable("filters by disc number for non-'*' wildcard patterns",
- func(pattern string, discNumber, expectedIdx int) {
- files := []string{
- createFile("album/disc1.jpg"),
- createFile("album/disc2.jpg"),
- }
- reader := &discArtworkReader{
- discNumber: discNumber,
- imgFiles: files,
- discFoldersRel: map[string]bool{"album": true},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromExternalFile(ctx, pattern)
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(files[expectedIdx]))
- },
- Entry("disc?.jpg, target disc 1 → disc1.jpg", "disc?.jpg", 1, 0),
- Entry("disc?.jpg, target disc 2 → disc2.jpg", "disc?.jpg", 2, 1),
- Entry("disc[0-9].jpg, target disc 1 → disc1.jpg", "disc[0-9].jpg", 1, 0),
- Entry("disc[0-9].jpg, target disc 2 → disc2.jpg", "disc[0-9].jpg", 2, 1),
- )
-
- It("matches file without number in multi-folder album by folder", func() {
- f1 := createFile("album/cd1/disc.jpg")
- f2 := createFile("album/cd2/disc.jpg")
- reader := &discArtworkReader{
- discNumber: 1,
- imgFiles: []string{f1, f2},
- discFoldersRel: map[string]bool{"album/cd1": true},
- isMultiFolder: true,
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromExternalFile(ctx, "disc*.*")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f1))
- })
-
- It("prefers disc number over folder when number is present", func() {
- // disc2.jpg in cd1 folder should match disc 2, not disc 1
- f1 := createFile("album/cd1/disc2.jpg")
- reader := &discArtworkReader{
- discNumber: 2,
- imgFiles: []string{f1},
- discFoldersRel: map[string]bool{"album/cd1": true},
- isMultiFolder: true,
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromExternalFile(ctx, "disc*.*")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f1))
- })
-
- It("does not match disc2.jpg when looking for disc 1", func() {
- f1 := createFile("album/disc2.jpg")
- reader := &discArtworkReader{
- discNumber: 1,
- imgFiles: []string{f1},
- discFoldersRel: map[string]bool{"album": true},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromExternalFile(ctx, "disc*.*")
- r, _, _ := sf()
- Expect(r).To(BeNil())
- })
- })
-
- Describe("fromDiscSubtitle", func() {
- var (
- ctx context.Context
- tmpDir string
- )
-
- BeforeEach(func() {
- ctx = context.Background()
- tmpDir = GinkgoT().TempDir()
- })
-
- createFile := func(relPath string) string {
- fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
- Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
- Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
- return relPath
- }
-
- It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() {
- f1 := createFile("album/The Blue Disc.jpg")
- reader := &discArtworkReader{
- discNumber: 1,
- imgFiles: []string{f1},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f1))
- })
-
- It("matches case-insensitively", func() {
- f1 := createFile("album/bonus tracks.png")
- reader := &discArtworkReader{
- discNumber: 2,
- imgFiles: []string{f1},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f1))
- })
-
- It("returns error when no matching file found", func() {
- f1 := createFile("album/cover.jpg")
- reader := &discArtworkReader{
- discNumber: 1,
- imgFiles: []string{f1},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
- _, _, err := sf()
- Expect(err).To(HaveOccurred())
- })
-
- It("matches first file when multiple extensions exist", func() {
- f1 := createFile("album/The Blue Disc.jpg")
- f2 := createFile("album/The Blue Disc.png")
- reader := &discArtworkReader{
- discNumber: 1,
- imgFiles: []string{f1, f2},
- lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
- }
-
- sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
- r, path, err := sf()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).ToNot(BeNil())
- r.Close()
- Expect(path).To(Equal(f1))
- })
- })
-
- 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"),
- )
-
- 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())
- })
- })
- })
-})
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
deleted file mode 100644
index 7b5e7b4a3..000000000
--- a/core/artwork/e2e/album_test.go
+++ /dev/null
@@ -1,470 +0,0 @@
-package e2e
-
-import (
- "testing/fstest"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/model"
- . "github.com/onsi/ginkgo/v2"
- . "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.
-var _ = Describe("Album artwork resolution", func() {
- BeforeEach(func() {
- setupResolutionHarness()
- })
-
- When("an album has a single folder with cover.jpg at the album root", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // └── cover.jpg ← matched by cover.*
- It("returns the album-root cover", func() {
- conf.Server.CoverArtPriority = defaultCoverPriority
- setLayout(fstest.MapFS{
- "Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
- "Artist/Album/cover.jpg": smallPNG("album-root"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
- })
- })
-
- // https://github.com/navidrome/navidrome/issues/5376
- // cover.* basenames tie across album-root and per-disc folders;
- // compareImageFiles must prefer shallower paths.
- When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
- // Artist/
- // └── Album/
- // ├── CD1/
- // │ ├── 01 - Track.mp3
- // │ └── cover.jpg ← should not win
- // ├── CD2/
- // │ ├── 01 - Track.mp3
- // │ └── cover.jpg
- // └── cover.jpg ← should win (album-root fallback)
- It("prefers the album-root cover over per-disc covers", func() {
- conf.Server.CoverArtPriority = defaultCoverPriority
- 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"),
- })
- 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")
- })
- })
-
- // https://github.com/navidrome/navidrome/issues/5376
- // folder.jpg basenames tie across album-root and per-disc folders;
- // compareImageFiles must prefer shallower paths.
- When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
- // Artist/
- // └── Album/
- // ├── CD1/
- // │ ├── 01 - Track.mp3
- // │ └── folder.jpg ← should not win
- // ├── CD2/
- // │ ├── 01 - Track.mp3
- // │ └── folder.jpg
- // └── folder.jpg ← should win (album-root fallback)
- It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
- conf.Server.CoverArtPriority = defaultCoverPriority
- 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"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
- })
- })
-
- // https://github.com/navidrome/navidrome/issues/5376
- // Single-subfolder albums must still consider the parent folder's images.
- When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
- // Artist/
- // └── Album/
- // ├── disc1/
- // │ └── 01 - Track.mp3
- // └── cover.jpg ← should win (parent-folder fallback)
- It("uses the parent-folder cover for single-disc-subfolder albums", func() {
- conf.Server.CoverArtPriority = defaultCoverPriority
- setLayout(fstest.MapFS{
- "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
- "Artist/Album/cover.jpg": smallPNG("album-root"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
- })
- })
-
- // https://github.com/navidrome/navidrome/issues/5456
- When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() {
- // Album/ (top-level folder, Path=".")
- // ├── CD1/
- // │ ├── 01 - Track.mp3
- // │ └── folder.jpg
- // ├── CD2/
- // │ ├── 01 - Track.mp3
- // │ └── folder.jpg
- // └── cover.jpg ← should win (album-root)
- It("prefers the album-root cover.jpg", func() {
- conf.Server.CoverArtPriority = defaultCoverPriority
- 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"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Album/cover.jpg")
- })
- })
-
- When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
- // └── cover.jpg
- It("returns the embedded image", 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"),
- })
- 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))
- })
- })
-
- When("CoverArtPriority lists external first but no external file is present", func() {
- // Artist/
- // └── Album/
- // └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
- It("falls through to embedded artwork", func() {
- conf.Server.CoverArtPriority = "external, embedded"
- setLayout(fstest.MapFS{
- "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
- })
- 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))
- })
- })
-
- When("the only cover file uses uppercase extension and a different case in its name", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // └── Cover.JPG ← matched case-insensitively by cover.*
- It("matches case-insensitively against cover.*", func() {
- conf.Server.CoverArtPriority = "cover.*, folder.*"
- setLayout(fstest.MapFS{
- "Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
- "Artist/Album/Cover.JPG": smallPNG("case-insensitive"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Artist/Album/Cover.JPG")
- })
- })
-
- When("two cover files have basenames that tie under the natural-sort tiebreaker", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // ├── cover.jpg ← wins (no numeric suffix)
- // └── cover.1.jpg
- It("prefers the file without a numeric suffix", 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"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
- })
- })
-
- 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() {
- conf.Server.CoverArtPriority = "cover.*, folder.*"
- setLayout(fstest.MapFS{
- "Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
- })
- scan()
- expectAlbumAbsent(firstAlbum())
- })
- })
-
- // Doc scenarios from:
- // https://www.navidrome.org/docs/usage/library/artwork/#albums
- // Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external".
- When("only folder.jpg is present (cover.* and front.* missing)", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // └── folder.jpg ← matched by folder.*
- It("falls through to folder.jpg", func() {
- conf.Server.CoverArtPriority = defaultCoverPriority
- setLayout(fstest.MapFS{
- "Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
- "Artist/Album/folder.jpg": smallPNG("folder"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
- })
- })
-
- When("only front.jpg is present (cover.* and folder.* missing)", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // └── front.jpg ← matched by front.*
- It("falls through to front.jpg", func() {
- conf.Server.CoverArtPriority = defaultCoverPriority
- setLayout(fstest.MapFS{
- "Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
- "Artist/Album/front.jpg": smallPNG("front"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Artist/Album/front.jpg")
- })
- })
-
- When("cover.*, folder.*, and front.* all exist in the same folder", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // ├── cover.jpg ← wins (cover.* is first in priority)
- // ├── folder.jpg
- // └── front.jpg
- It("prefers cover.* (first in CoverArtPriority)", 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"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
- })
- })
-
- When("only folder.* and front.* exist (priority order check)", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // ├── folder.jpg ← wins (folder.* comes before front.*)
- // └── front.jpg
- It("prefers folder.* over front.*", 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"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
- })
- })
-
- When("three cover files tie by basename and differ only by numeric suffix", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // ├── cover.jpg ← wins (no numeric suffix)
- // ├── cover.1.jpg
- // └── cover.2.jpg
- It("selects the unsuffixed file first regardless of numeric-suffix order", 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"),
- })
- scan()
- expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
- })
- })
-
- When("CoverArtPriority contains an unknown pattern before a matching one", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // └── cover.jpg ← wins (unknown "bogus.*" is skipped)
- It("skips the unknown pattern and falls through to the matching one", 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"),
- })
- 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")
- })
- })
-
- When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3 (no embedded picture)
- // └── cover.jpg ← wins (embedded skipped, falls through)
- It("falls through to the next priority entry", func() {
- conf.Server.CoverArtPriority = "embedded, 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")
- })
- })
-})
diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go
deleted file mode 100644
index 38c28a9b2..000000000
--- a/core/artwork/e2e/artist_test.go
+++ /dev/null
@@ -1,291 +0,0 @@
-package e2e
-
-import (
- "os"
- "path/filepath"
- "testing/fstest"
-
- "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"
-)
-
-// 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()
- })
-
- When("the artist folder contains an artist.jpg", func() {
- // Artist/
- // ├── artist.jpg ← matched by artist.*
- // └── Album/
- // └── 01 - Track.mp3
- It("returns the artist.* image from the artist folder", 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"),
- })
- scan()
- expectArtistFolder(soleArtist(), "Artist/artist.jpg")
- })
- })
-
- When("artist.* only exists inside an album folder", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // └── artist.jpg ← matched by album/artist.*
- It("falls through to album/artist.* and returns that image", 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"),
- })
- scan()
- expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg")
- })
- })
-
- When("both the artist folder and an album folder have an artist.* image", func() {
- // Artist/
- // ├── artist.jpg ← wins (artist.* before album/artist.*)
- // └── Album/
- // ├── 01 - Track.mp3
- // └── artist.jpg
- It("prefers the artist-folder image (artist.* comes before album/artist.*)", 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"),
- })
- 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")
- })
- })
-
- When("an artist has an uploaded image and a matching artist.* file", func() {
- // /
- // └── artwork/
- // └── artist/
- // └── _upload.jpg ← wins (uploaded image beats the priority chain)
- // Library:
- // Artist/
- // ├── artist.jpg (ignored — uploaded image comes first)
- // └── Album/
- // └── 01 - Track.mp3
- It("prefers the uploaded image over any priority-chain match", 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"),
- })
- scan()
- ar := soleArtist()
-
- uploaded := ar.ID + "_upload.jpg"
- writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
- ar.UploadedImage = uploaded
- Expect(rds.Artist().Put(rctx, &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")))
- })
- })
-
- When("ArtistArtPriority uses album/ (not just album/artist.*)", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // └── artist.jpg ← matched by album/artist.*
- It("resolves the pattern against the artist's album image files", 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"),
- })
- 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))
- })
- })
-
- When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() {
- // /
- // └── Artist.jpg ← matched by artist name (image-folder source)
- // Library:
- // Artist/
- // └── Album/
- // └── 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())
- conf.Server.ArtistImageFolder = imgFolder
- conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
-
- setLayout(fstest.MapFS{
- "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
- })
- 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")))
- })
- })
-})
-
-func soleArtist() model.Artist {
- GinkgoHelper()
- artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{
- Filters: squirrel.Eq{"artist.name": "Artist"},
- })
- Expect(err).ToNot(HaveOccurred())
- if len(artists) == 0 {
- Fail("sole artist not found")
- return model.Artist{}
- }
- return artists[0]
-}
diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go
deleted file mode 100644
index 5fdd5f27b..000000000
--- a/core/artwork/e2e/disc_test.go
+++ /dev/null
@@ -1,333 +0,0 @@
-package e2e
-
-import (
- "fmt"
- "testing/fstest"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/core/artwork"
- . "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()
- })
-
- When("the album is single-disc with a disc1.jpg in the only folder", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // └── disc1.jpg ← matched by disc*.*
- It("returns the disc1.jpg image (matched as disc*.*)", 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"),
- })
- scan()
- expectDiscImage(firstAlbum(), 1, "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() {
- 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))
- })
- })
-
- When("the album has no per-disc image but has an album cover", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // └── cover.jpg ← album-level fallback (no disc art present)
- It("falls back to the album cover", func() {
- conf.Server.DiscArtPriority = "disc*.*, cd*.*"
- conf.Server.CoverArtPriority = defaultCoverPriority
- setLayout(fstest.MapFS{
- "Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
- "Artist/Album/cover.jpg": smallPNG("album-cover"),
- })
- scan()
- expectDiscImage(firstAlbum(), 1, "album-cover")
- })
- })
-
- When("multiple disc images exist in the same folder (disc1 vs disc10)", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3
- // ├── disc1.jpg ← matches request for disc 1
- // └── disc10.jpg
- It("matches the requested disc number, not a higher-numbered one", 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"),
- })
- scan()
- expectDiscImage(firstAlbum(), 1, "disc-one")
- })
- })
-
- When("a multi-disc album has per-disc covers", func() {
- // Artist/
- // └── Album/
- // ├── CD1/
- // │ ├── 01 - Track.mp3
- // │ └── disc1.jpg ← matches request for disc 1
- // └── CD2/
- // ├── 01 - Track.mp3
- // └── disc2.jpg ← matches request for disc 2
- It("returns the requested disc's image", func() {
- conf.Server.DiscArtPriority = "disc*.*"
- 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"),
- })
- scan()
- expectDiscImage(firstAlbum(), 2, "disc-2")
- })
- })
-
- // Doc scenarios from:
- // https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art
- // Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded".
- When("a disc subfolder has a cd2.png image", func() {
- // Artist/
- // └── Album/
- // ├── CD1/
- // │ ├── 01 - Track.mp3
- // │ └── disc1.jpg
- // └── CD2/
- // ├── 01 - Track.mp3
- // └── cd2.png ← matched by cd*.* for disc 2
- It("matches via the cd*.* pattern", func() {
- conf.Server.DiscArtPriority = defaultDiscPriority
- 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"),
- })
- scan()
- expectDiscImage(firstAlbum(), 2, "cd-2")
- })
- })
-
- When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() {
- // Artist/
- // └── Album/
- // ├── CD1/
- // │ ├── 01 - Track.mp3
- // │ └── cover.jpg ← matched by cover.* inside disc folder
- // └── CD2/
- // ├── 01 - Track.mp3
- // └── cover.jpg
- It("falls through to cover.* inside the disc folder", func() {
- conf.Server.DiscArtPriority = defaultDiscPriority
- 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"),
- })
- scan()
- expectDiscImage(firstAlbum(), 1, "disc1-cover")
- })
- })
-
- When("DiscArtPriority is the empty string", func() {
- // Artist/
- // └── Album/
- // ├── CD1/
- // │ ├── 01 - Track.mp3
- // │ └── disc1.jpg (ignored — DiscArtPriority is empty)
- // ├── CD2/
- // │ ├── 01 - Track.mp3
- // │ └── cd2.png (ignored — DiscArtPriority is empty)
- // └── cover.jpg ← used for every disc (album-level fallback)
- It("skips every disc-level source and returns the album cover", func() {
- conf.Server.DiscArtPriority = ""
- conf.Server.CoverArtPriority = defaultCoverPriority
- 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"),
- })
- scan()
-
- al := firstAlbum()
- for _, n := range []int{1, 2} {
- expectDiscImage(al, n, "album-cover")
- }
- })
- })
-
- When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() {
- // Artist/
- // └── Album/
- // ├── disc1/
- // │ ├── disc1.jpg ← matched by disc*.* for disc 1
- // │ ├── 01 - Track.mp3
- // │ └── 02 - Track.mp3
- // ├── disc2/
- // │ ├── cd2.png ← matched by cd*.* for disc 2
- // │ ├── 01 - Track.mp3
- // │ └── 02 - Track.mp3
- // └── cover.jpg (album-level fallback, unused here)
- It("matches the per-disc image for each disc", func() {
- conf.Server.DiscArtPriority = defaultDiscPriority
- conf.Server.CoverArtPriority = defaultCoverPriority
- setLayout(fstest.MapFS{
- "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
- "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"),
- })
- scan()
-
- al := firstAlbum()
- expectDiscImage(al, 1, "disc-1")
- expectDiscImage(al, 2, "cd-2")
- })
- })
-
- When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
- // └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword
- It("selects the subtitle-named image", 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"),
- })
- scan()
- expectDiscImage(firstAlbum(), 1, "bonus-tracks")
- })
- })
-
- // Reproduces https://github.com/navidrome/navidrome/issues/5456
- // Deeply nested layout matching the reporter's actual structure.
- When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() {
- // Genre/Artist/Album/ ← album root with cover.jpg
- // ├── cover.jpg ← album-level cover
- // ├── Disc 01 (Subtitle)/
- // │ ├── 01 - Track.mp3
- // │ └── folder.jpg ← disc 1 art
- // ├── Disc 02 (Subtitle)/
- // │ ├── 01 - Track.mp3
- // │ └── folder.jpg
- // └── ... (12 discs)
- It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
- conf.Server.DiscArtPriority = defaultDiscPriority
- conf.Server.CoverArtPriority = defaultCoverPriority
- discNames := []string{
- "Disc 01 (Birth of the Dead - The Studio Sides)",
- "Disc 02 (Birth of the Dead - The Live Sides)",
- "Disc 03 (The Grateful Dead)",
- "Disc 04 (Anthem of the Sun)",
- "Disc 05 (Aoxomoxoa)",
- "Disc 06 (Live; Dead)",
- "Disc 07 (Workingman's Dead)",
- "Disc 08 (American Beauty)",
- "Disc 09 (Grateful Dead)",
- "Disc 10 (Europe '72)",
- "Disc 11 (Europe '72)",
- "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"),
- }
- 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))
- }
- setLayout(layout)
- scan()
-
- al := firstAlbum()
- expectAlbumFolderCover(al, "(2001) The Golden Road/cover.jpg")
- for i := range discNames {
- discNum := i + 1
- expectDiscImage(al, discNum, fmt.Sprintf("disc-%02d-folder", discNum))
- }
- })
- })
-
- // https://github.com/navidrome/navidrome/issues/5456
- // Top-level album variant — album folder at library root (Path=".").
- When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() {
- // Album/ (top-level, Path=".")
- // ├── cover.jpg ← album-level cover
- // ├── Disc 01/
- // │ ├── 01 - Track.mp3
- // │ └── folder.jpg ← disc 1 art
- // ├── Disc 02/
- // │ ├── 01 - Track.mp3
- // │ └── folder.jpg
- // └── Disc 03/
- // ├── 01 - Track.mp3
- // └── folder.jpg
- It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
- conf.Server.DiscArtPriority = defaultDiscPriority
- conf.Server.CoverArtPriority = defaultCoverPriority
- layout := fstest.MapFS{
- "Album/cover.jpg": smallPNG("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))
- }
- setLayout(layout)
- scan()
-
- al := firstAlbum()
- expectAlbumFolderCover(al, "Album/cover.jpg")
- for i := 1; i <= 3; i++ {
- expectDiscImage(al, i, fmt.Sprintf("disc-%02d-folder", i))
- }
- })
- })
-
- When("discsubtitle is set but no image filename matches the subtitle", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
- // └── cover.jpg ← wins (discsubtitle has no match, falls through)
- It("falls through to the next priority entry", 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"),
- })
- scan()
- expectDiscImage(firstAlbum(), 1, "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/mediafile_test.go b/core/artwork/e2e/mediafile_test.go
deleted file mode 100644
index 3ddb9dc48..000000000
--- a/core/artwork/e2e/mediafile_test.go
+++ /dev/null
@@ -1,149 +0,0 @@
-package e2e
-
-import (
- "testing/fstest"
-
- "github.com/Masterminds/squirrel"
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/model"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-// Doc reference:
-// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles
-// Navidrome resolves mediafile artwork in this order:
-// 1. Embedded image from the mediafile itself
-// 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() {
- BeforeEach(func() {
- setupResolutionHarness()
- })
-
- When("a multi-disc album track has no embedded art", func() {
- // Artist/
- // └── Album/
- // ├── CD1/
- // │ ├── 01 - Track.mp3
- // │ └── disc1.jpg
- // ├── CD2/
- // │ ├── 01 - Track.mp3 ← track requested
- // │ └── disc2.jpg ← wins (disc-level before album-level)
- // └── cover.jpg
- It("falls back to the disc-level artwork (not the album cover)", func() {
- conf.Server.CoverArtPriority = defaultCoverPriority
- conf.Server.DiscArtPriority = defaultDiscPriority
- 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"),
- })
- scan()
-
- mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
- Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("disc-2")))
- })
- })
-
- When("a single-disc album track has no embedded art", func() {
- // Artist/
- // └── Album/
- // ├── 01 - Track.mp3 ← track requested
- // └── cover.jpg ← wins (album-level fallback, no disc subfolder)
- It("falls back to the album cover", func() {
- conf.Server.CoverArtPriority = defaultCoverPriority
- conf.Server.DiscArtPriority = defaultDiscPriority
- setLayout(fstest.MapFS{
- "Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
- "Artist/Album/cover.jpg": smallPNG("album-cover"),
- })
- scan()
-
- mf := mediafileOn("Artist/Album/01 - Track.mp3")
- Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-cover")))
- })
- })
-
- When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() {
- // Artist/
- // └── Album/
- // ├── CD1/
- // │ └── 01 - Track.mp3
- // ├── CD2/
- // │ └── 01 - Track.mp3 ← track requested
- // └── cover.jpg ← wins (no disc image → album-level fallback)
- It("falls through from disc to album cover", func() {
- conf.Server.CoverArtPriority = defaultCoverPriority
- conf.Server.DiscArtPriority = defaultDiscPriority
- 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"),
- })
- 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")))
- })
- })
-})
-
-func mediafileOn(relPath string) model.MediaFile {
- GinkgoHelper()
- mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{
- Filters: squirrel.Like{"media_file.path": relPath},
- })
- Expect(err).ToNot(HaveOccurred())
- if len(mfs) == 0 {
- Fail("mediafile not found: " + relPath)
- return model.MediaFile{}
- }
- return mfs[0]
-}
diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go
deleted file mode 100644
index 0d862b20e..000000000
--- a/core/artwork/e2e/playlist_test.go
+++ /dev/null
@@ -1,213 +0,0 @@
-package e2e
-
-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"
-)
-
-// Playlist artwork resolves in this priority order:
-// 1. Uploaded image (/artwork/playlist/)
-// 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
-//
-// 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.
-var _ = Describe("Playlist artwork resolution", func() {
- BeforeEach(func() {
- setupResolutionHarness()
- })
-
- When("a playlist has an uploaded image", func() {
- // /
- // └── artwork/
- // └── 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"))
- 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")))
- })
- })
-
- When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() {
- // /
- // ├── MyList.m3u
- // └── MyList.jpg ← matched by sidecar (same basename, case-insensitive)
- 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())
-
- 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")))
- })
- })
-
- When("a playlist's sidecar uses a different extension case", func() {
- // /
- // ├── MyList.m3u
- // └── MyList.PNG ← matched case-insensitively
- 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())
-
- pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath})
-
- Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar-png")))
- })
- })
-
- When("a playlist has an ExternalImageURL pointing to a local file", func() {
- // /
- // └── cover.jpg ← absolute path stored in ExternalImageURL
- It("returns the local file regardless of EnableM3UExternalAlbumArt", 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())
-
- pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath})
-
- Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("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() {
- 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())
- })
- })
-
- 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() {
- 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))
- })
- })
-
- When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() {
- // Library:
- // Artist/
- // ├── AlbumA/
- // │ ├── 01 - Track.mp3
- // │ └── cover.png ← 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).
- 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/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}),
- "Artist/AlbumB/cover.png": smallPNG("albumB"),
- })
- scan()
-
- mfs, err := rds.MediaFile().GetAll(rctx, 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())
-
- 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).
- 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]))
- })
- })
-})
-
-func putPlaylist(pl model.Playlist) model.Playlist {
- GinkgoHelper()
- if pl.OwnerID == "" {
- pl.OwnerID = "admin-1"
- }
- Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
- return pl
-}
diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go
deleted file mode 100644
index 72af215d1..000000000
--- a/core/artwork/e2e/radio_test.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package e2e
-
-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()
- })
-
- When("a radio has an uploaded image", func() {
- // /
- // └── artwork/
- // └── 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())
-
- 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")))
- })
- })
-
- 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() {
- rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
- Expect(rds.Radio().Put(rctx, &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))
- })
- })
-})
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/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
deleted file mode 100644
index 84e088c68..000000000
--- a/core/artwork/library_fs.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package artwork
-
-import (
- "context"
- "net/url"
- "path/filepath"
- "strings"
-
- "github.com/navidrome/navidrome/core/storage"
- "github.com/navidrome/navidrome/model"
-)
-
-// libraryView bundles the MusicFS for a library with its absolute root path,
-// so readers can open library-relative paths through FS and compose absolute
-// paths (for ffmpeg, which is path-based) via Abs.
-type libraryView struct {
- FS storage.MusicFS
- absRoot string
-}
-
-// Abs returns the absolute path for a library-relative path. Returns "" for an
-// empty rel so callers (fromFFmpegTag) can treat it as "no path available".
-func (v libraryView) Abs(rel string) string {
- if rel == "" {
- return ""
- }
- return filepath.Join(v.absRoot, rel)
-}
-
-// 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)
- if err != nil {
- return libraryView{}, err
- }
- s, err := storage.For(lib.Path)
- if err != nil {
- return libraryView{}, err
- }
- fs, err := s.FS()
- 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
-}
diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go
deleted file mode 100644
index 868c5e45a..000000000
--- a/core/artwork/library_fs_test.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package artwork
-
-import (
- "context"
-
- "github.com/navidrome/navidrome/core/storage/storagetest"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("loadLibraryView", Ordered, func() {
- var ctx context.Context
- var ds *tests.MockDataStore
-
- BeforeAll(func() {
- storagetest.Register("fake", &storagetest.FakeFS{})
- })
-
- BeforeEach(func() {
- ctx = GinkgoT().Context()
- ds = &tests.MockDataStore{MockedLibrary: &tests.MockLibraryRepo{}}
- })
-
- 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())
-
- lib, err := loadLibraryView(ctx, ds, 1)
- Expect(err).ToNot(HaveOccurred())
- Expect(lib.FS).ToNot(BeNil())
- 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())
- _, 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..55d8b4352
--- /dev/null
+++ b/core/artwork/reader_album.go
@@ -0,0 +1,123 @@
+package artwork
+
+import (
+ "context"
+ "crypto/md5"
+ "fmt"
+ "io"
+ "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/core/ffmpeg"
+ "github.com/navidrome/navidrome/model"
+)
+
+type albumArtworkReader struct {
+ cacheKey
+ a *artwork
+ provider external.Provider
+ album model.Album
+ updatedAt *time.Time
+ imgFiles []string
+ rootFolder string
+}
+
+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
+ }
+ a := &albumArtworkReader{
+ a: artwork,
+ provider: provider,
+ album: *al,
+ updatedAt: imagesUpdateAt,
+ imgFiles: imgFiles,
+ rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""),
+ }
+ a.cacheKey.artID = artID
+ if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) {
+ a.cacheKey.lastUpdate = *a.updatedAt
+ } else {
+ a.cacheKey.lastUpdate = al.UpdatedAt
+ }
+ return a, nil
+}
+
+func (a *albumArtworkReader) Key() string {
+ var hash [16]byte
+ if conf.Server.EnableExternalServices {
+ hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority))
+ }
+ return fmt.Sprintf(
+ "%s.%x.%t",
+ a.cacheKey.Key(),
+ hash,
+ conf.Server.EnableExternalServices,
+ )
+}
+func (a *albumArtworkReader) LastUpdated() time.Time {
+ return a.album.UpdatedAt
+}
+
+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.Split(strings.ToLower(priority), ",") {
+ pattern = strings.TrimSpace(pattern)
+ switch {
+ case pattern == "embedded":
+ embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
+ ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
+ case pattern == "external":
+ ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
+ case len(a.imgFiles) > 0:
+ ff = append(ff, fromExternalFile(ctx, 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
+ }
+ var paths []string
+ var imgFiles []string
+ var updatedAt time.Time
+ for _, f := range folders {
+ path := f.AbsolutePath()
+ paths = append(paths, path)
+ if f.ImagesUpdatedAt.After(updatedAt) {
+ updatedAt = f.ImagesUpdatedAt
+ }
+ for _, img := range f.ImageFiles {
+ imgFiles = append(imgFiles, filepath.Join(path, img))
+ }
+ }
+
+ // Sort image files to ensure consistent selection of cover art
+ // This prioritizes files from lower-numbered disc folders by sorting the paths
+ slices.Sort(imgFiles)
+
+ return paths, imgFiles, &updatedAt, nil
+}
diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go
new file mode 100644
index 000000000..2665632b9
--- /dev/null
+++ b/core/artwork/reader_album_test.go
@@ -0,0 +1,76 @@
+package artwork
+
+import (
+ "context"
+ "path/filepath"
+ "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{
+ result: []model.Folder{
+ {
+ Path: "Artist/Album/Disc1",
+ ImagesUpdatedAt: expectedAt,
+ ImageFiles: []string{"cover.jpg", "back.jpg"},
+ },
+ {
+ Path: "Artist/Album/Disc2",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ {
+ Path: "Artist/Album/Disc10",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ },
+ err: nil,
+ }
+ ds = &fakeDataStore{
+ folderRepo: repo,
+ }
+ album = model.Album{
+ ID: "album1",
+ Name: "Album",
+ FolderIDs: []string{"folder1", "folder2", "folder3"},
+ }
+ })
+
+ It("returns sorted image files", func() {
+ _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(*imagesUpdatedAt).To(Equal(expectedAt))
+
+ // Check that image files are sorted alphabetically
+ Expect(imgFiles).To(HaveLen(4))
+
+ // The files should be sorted by full path
+ Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
+ Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
+ Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
+ Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
+ })
+ })
+})
diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go
new file mode 100644
index 000000000..cb029a16e
--- /dev/null
+++ b/core/artwork/reader_artist.go
@@ -0,0 +1,186 @@
+package artwork
+
+import (
+ "context"
+ "crypto/md5"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "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
+}
+
+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
+ }
+ a := &artistReader{
+ a: artwork,
+ provider: provider,
+ artist: *ar,
+ artistFolder: artistFolder,
+ imgFiles: imgFiles,
+ }
+ // 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 artistFolderLastUpdate.After(a.cacheKey.lastUpdate) {
+ a.cacheKey.lastUpdate = artistFolderLastUpdate
+ }
+ a.cacheKey.artID = artID
+ return a, nil
+}
+
+func (a *artistReader) Key() string {
+ hash := md5.Sum([]byte(conf.Server.Agents + conf.Server.Spotify.ID))
+ 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) {
+ var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)
+ return selectImageReader(ctx, a.artID, ff...)
+}
+
+func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
+ var ff []sourceFunc
+ for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
+ pattern = strings.TrimSpace(pattern)
+ switch {
+ case pattern == "external":
+ ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
+ case strings.HasPrefix(pattern, "album/"):
+ ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
+ default:
+ ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern))
+ }
+ }
+ return ff
+}
+
+func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
+ return func() (io.ReadCloser, string, error) {
+ current := artistFolder
+ for i := 0; i < maxArtistFolderTraversalDepth; i++ {
+ if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
+ return reader, path, nil
+ }
+
+ parent := filepath.Dir(current)
+ if parent == current {
+ break
+ }
+ current = parent
+ }
+ return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder)
+ }
+}
+
+func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) {
+ log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder)
+ fsys := os.DirFS(folder)
+ matches, err := fs.Glob(fsys, pattern)
+ if err != nil {
+ log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err)
+ return nil, "", err
+ }
+
+ for _, m := range matches {
+ if !model.IsImageFile(m) {
+ continue
+ }
+ filePath := filepath.Join(folder, m)
+ f, err := os.Open(filePath)
+ if err != nil {
+ log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
+ continue
+ }
+ return f, filePath, nil
+ }
+
+ return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder)
+}
+
+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
+}
diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go
new file mode 100644
index 000000000..527b0849f
--- /dev/null
+++ b/core/artwork/reader_artist_test.go
@@ -0,0 +1,391 @@
+package artwork
+
+import (
+ "context"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ "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("/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("/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
+ testFunc sourceFunc
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ tempDir = GinkgoT().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, 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 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, 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, 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, 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.jpg"), []byte("jpg image"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.txt"), []byte("text file"), 0600)).To(Succeed())
+
+ testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
+ })
+
+ It("returns the first valid image file", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+
+ // Should return an image file, not the text file
+ Expect(path).To(SatisfyAny(
+ ContainSubstring("artist.jpg"),
+ ContainSubstring("artist.png"),
+ ))
+ Expect(path).ToNot(ContainSubstring("artist.txt"))
+ 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, 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, 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, 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, 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()
+ })
+ })
+ })
+})
+
+type fakeFolderRepo struct {
+ model.FolderRepository
+ result []model.Folder
+ err error
+}
+
+func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
+ return f.result, f.err
+}
+
+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_mediafile.go b/core/artwork/reader_mediafile.go
new file mode 100644
index 000000000..c72d9543d
--- /dev/null
+++ b/core/artwork/reader_mediafile.go
@@ -0,0 +1,65 @@
+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
+}
+
+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
+ }
+ a := &mediafileArtworkReader{
+ a: artwork,
+ mediafile: *mf,
+ album: *al,
+ }
+ a.cacheKey.artID = artID
+ if al.UpdatedAt.After(mf.UpdatedAt) {
+ a.cacheKey.lastUpdate = al.UpdatedAt
+ } else {
+ a.cacheKey.lastUpdate = mf.UpdatedAt
+ }
+ 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 {
+ path := a.mediafile.AbsolutePath()
+ ff = []sourceFunc{
+ fromTag(ctx, path),
+ fromFFmpegTag(ctx, a.a.ffmpeg, path),
+ }
+ }
+ 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..a9f289ad8
--- /dev/null
+++ b/core/artwork/reader_playlist.go
@@ -0,0 +1,147 @@
+package artwork
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "image"
+ "image/draw"
+ "image/png"
+ "io"
+ "time"
+
+ "github.com/disintegration/imaging"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/slice"
+)
+
+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
+ return a, nil
+}
+
+func (a *playlistArtworkReader) LastUpdated() time.Time {
+ return a.lastUpdate
+}
+
+func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
+ ff := []sourceFunc{
+ a.fromGeneratedTiledCover(ctx),
+ fromAlbumPlaceholder(),
+ }
+ return selectImageReader(ctx, a.artID, ff...)
+}
+
+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 imaging.Fill(img, tileSize/2, tileSize/2, imaging.Center, imaging.Lanczos), 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
+}
diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go
new file mode 100644
index 000000000..83e6e25c2
--- /dev/null
+++ b/core/artwork/reader_resized.go
@@ -0,0 +1,116 @@
+package artwork
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "image"
+ "image/jpeg"
+ "image/png"
+ "io"
+ "time"
+
+ "github.com/disintegration/imaging"
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+)
+
+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.CoverJpegQuality)
+}
+
+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 := resizeImage(orig, a.size, a.square)
+ 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
+ }
+ return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
+}
+
+func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
+ original, format, err := image.Decode(reader)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ bounds := original.Bounds()
+ originalSize := max(bounds.Max.X, bounds.Max.Y)
+
+ if originalSize <= size && !square {
+ return nil, originalSize, nil
+ }
+
+ var resized image.Image
+ if originalSize >= size {
+ resized = imaging.Fit(original, size, size, imaging.Lanczos)
+ } else {
+ if bounds.Max.Y < bounds.Max.X {
+ resized = imaging.Resize(original, size, 0, imaging.Lanczos)
+ } else {
+ resized = imaging.Resize(original, 0, size, imaging.Lanczos)
+ }
+ }
+ if square {
+ bg := image.NewRGBA(image.Rect(0, 0, size, size))
+ resized = imaging.OverlayCenter(bg, resized, 1)
+ }
+
+ buf := new(bytes.Buffer)
+ if format == "png" || square {
+ err = png.Encode(buf, resized)
+ } else {
+ err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
+ }
+ return buf, originalSize, err
+}
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..4250a373b 100644
--- a/core/artwork/sources.go
+++ b/core/artwork/sources.go
@@ -3,54 +3,74 @@ package artwork
import (
"bytes"
"context"
- "errors"
"fmt"
"io"
- "io/fs"
"net/http"
"net/url"
+ "os"
"path/filepath"
+ "reflect"
"regexp"
+ "runtime"
"strings"
"time"
+ "github.com/dhowden/tag"
+ "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"
- "go.senan.xyz/taglib"
+ "github.com/navidrome/navidrome/resources"
)
-// 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 fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc {
+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, 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)
+ f, err := os.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 f, file, err
}
return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files)
}
@@ -63,66 +83,51 @@ var picTypeRegexes = []*regexp.Regexp{
regexp.MustCompile(`(?i).*cover.*`),
}
-func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc {
+func fromTag(ctx context.Context, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
- if relPath == "" {
+ if path == "" {
return nil, "", nil
}
- f, err := libFS.Open(relPath)
+ f, err := os.Open(path)
if err != nil {
- return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err)
+ return nil, "", err
}
- rs, ok := f.(io.ReadSeeker)
- if !ok {
- f.Close()
- return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath)
- }
- tf, err := taglib.OpenStream(rs,
- taglib.WithReadStyle(taglib.ReadStyleFast),
- taglib.WithFilename(relPath),
- )
- if err != nil {
- f.Close()
- return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err)
- }
- // Close in LIFO order: tf first (it holds rs internally), then f.
defer f.Close()
- defer tf.Close()
- images := tf.Properties().Images
- if len(images) == 0 {
- return nil, "", fmt.Errorf("no embedded image found in %s", relPath)
+ m, err := tag.ReadFrom(f)
+ if err != nil {
+ return nil, "", err
}
- imageIndex := findBestImageIndex(ctx, images, relPath)
- data, err := tf.Image(imageIndex)
- if err != nil || len(data) == 0 {
- return nil, "", fmt.Errorf("could not load embedded image from %s", relPath)
+ types := m.PictureTypes()
+ if len(types) == 0 {
+ return nil, "", fmt.Errorf("no embedded image found in %s", path)
}
- return io.NopCloser(bytes.NewReader(data)), relPath, nil
- }
-}
-func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path string) int {
- 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)
- return i
+ var picture *tag.Picture
+ for _, regex := range picTypeRegexes {
+ for _, t := range types {
+ if regex.MatchString(t) {
+ log.Trace(ctx, "Found embedded image", "type", t, "path", path)
+ picture = m.Pictures(t)
+ break
+ }
+ }
+ if picture != nil {
+ break
}
}
+ if picture == nil {
+ log.Trace(ctx, "Could not find a front image. Getting the first one", "type", types[0], "path", path)
+ picture = m.Picture()
+ }
+ if picture == nil {
+ return nil, "", fmt.Errorf("could not load embedded image from %s", path)
+ }
+ return io.NopCloser(bytes.NewReader(picture.Data)), path, nil
}
- log.Trace(ctx, "Artwork: Could not find a front image. Getting the first one", "type", images[0].Type, "path", path)
- return 0
}
-// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess
-// and cannot read from arbitrary fs.FS implementations; piping via stdin is a
-// non-trivial refactor with stream/seek implications.
-//
-// TODO(artwork-musicfs): when the storage backing the library is not local
-// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source
-// func to return (nil, "", nil) so callers fall through cleanly.
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
@@ -132,46 +137,55 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
if err != nil {
return nil, "", err
}
- // Validate that the stream actually contains image data by reading the first byte.
- // ffmpeg.ExtractImage returns a pipe reader that may fail asynchronously if the
- // file has no video/image stream (e.g., an MP3 without embedded art).
- buf := make([]byte, 1)
- n, err := r.Read(buf)
- if n == 0 || err != nil {
- r.Close()
- return nil, "", fmt.Errorf("ffmpeg produced no image data for %s: %w", path, err)
- }
- return readCloser{Reader: io.MultiReader(bytes.NewReader(buf[:n]), r), Closer: r}, path, nil
+ return r, path, nil
}
}
-// readCloser combines a Reader and a Closer into an io.ReadCloser.
-type readCloser struct {
- io.Reader
- io.Closer
+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
+ }
}
-// 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 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
- }
+ resp, err := hc.Do(req)
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
deleted file mode 100644
index bc81b56e7..000000000
--- a/core/artwork/sources_internal_test.go
+++ /dev/null
@@ -1,178 +0,0 @@
-package artwork
-
-import (
- "bytes"
- "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{
- "Artist/Album/cover.jpg": &fstest.MapFile{Data: []byte("cover-bytes")},
- }
- f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/cover.jpg"}, "cover.*")
- r, path, err := f()
- Expect(err).ToNot(HaveOccurred())
- defer r.Close()
- b, _ := io.ReadAll(r)
- Expect(b).To(Equal([]byte("cover-bytes")))
- Expect(path).To(Equal("Artist/Album/cover.jpg"))
- })
-
- It("returns an error when no file matches", func() {
- fsys := fstest.MapFS{
- "Artist/Album/something.txt": &fstest.MapFile{Data: []byte("x")},
- }
- f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/something.txt"}, "cover.*")
- _, _, err := f()
- Expect(err).To(HaveOccurred())
- })
-
- It("skips files that fail to open and tries the next match", func() {
- fsys := fstest.MapFS{
- "a/cover.jpg": &fstest.MapFile{Data: []byte("a")},
- }
- // "missing/cover.jpg" is in candidates but not in the FS — should be skipped.
- f := fromExternalFile(GinkgoT().Context(), fsys, []string{"missing/cover.jpg", "a/cover.jpg"}, "cover.*")
- r, path, err := f()
- Expect(err).ToNot(HaveOccurred())
- defer r.Close()
- b, _ := io.ReadAll(r)
- 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() {
- It("opens an embedded image via fs.FS", func() {
- fsys := os.DirFS("tests/fixtures/artist/an-album")
- f := fromTag(GinkgoT().Context(), fsys, "test.mp3")
- r, path, err := f()
- Expect(err).ToNot(HaveOccurred())
- defer r.Close()
- Expect(path).To(Equal("test.mp3"))
- b, _ := io.ReadAll(r)
- Expect(b).ToNot(BeEmpty())
- })
-
- It("returns nil reader when the relative path is empty", func() {
- f := fromTag(GinkgoT().Context(), os.DirFS("."), "")
- r, _, err := f()
- Expect(err).ToNot(HaveOccurred())
- Expect(r).To(BeNil())
- })
-
- It("errors when the FS file is not seekable", func() {
- fsys := nonSeekableFS{data: []byte("garbage")}
- f := fromTag(GinkgoT().Context(), fsys, "x.mp3")
- _, _, err := f()
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("not seekable"))
- })
-})
-
-// nonSeekableFS is a single-file fs.FS whose Open returns a non-seekable file.
-type nonSeekableFS struct{ data []byte }
-
-func (n nonSeekableFS) Open(name string) (fs.File, error) {
- return &nonSeekableFile{r: bytes.NewReader(n.data)}, nil
-}
-
-type nonSeekableFile struct{ r *bytes.Reader }
-
-func (n *nonSeekableFile) Read(p []byte) (int, error) { return n.r.Read(p) }
-func (n *nonSeekableFile) Close() error { return nil }
-func (n *nonSeekableFile) Stat() (fs.FileInfo, error) { return nil, errors.New("not implemented") }
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..fd2b670a4 100644
--- a/core/auth/auth.go
+++ b/core/auth/auth.go
@@ -4,13 +4,11 @@ import (
"cmp"
"context"
"crypto/sha256"
- "errors"
- "slices"
"sync"
"time"
"github.com/go-chi/jwtauth/v5"
- "github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@@ -21,74 +19,68 @@ 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)
+ }
+ }
+
+ TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
})
}
-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
+func createBaseClaims() map[string]any {
+ tokenClaims := map[string]any{}
+ tokenClaims[jwt.IssuerKey] = consts.JWTIssuer
+ return tokenClaims
}
-func CreatePublicToken(claims Claims) (string, error) {
- claims.Issuer = consts.JWTIssuer
- _, token, err := PublicTokenAuth.Encode(claims.ToMap())
+func CreatePublicToken(claims map[string]any) (string, error) {
+ tokenClaims := createBaseClaims()
+ for k, v := range claims {
+ tokenClaims[k] = v
+ }
+ _, token, err := TokenAuth.Encode(tokenClaims)
+
return token, err
}
-func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
- claims.Issuer = consts.JWTIssuer
+func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, error) {
+ tokenClaims := createBaseClaims()
if !exp.IsZero() {
- claims.ExpiresAt = exp
+ tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
}
- _, token, err := PublicTokenAuth.Encode(claims.ToMap())
- return token, err
-}
+ for k, v := range claims {
+ tokenClaims[k] = v
+ }
+ _, token, err := TokenAuth.Encode(tokenClaims)
-func userClaims(u *model.User, audience []string) Claims {
- return Claims{
- Issuer: consts.JWTIssuer,
- Subject: u.UserName,
- IssuedAt: time.Now(),
- UserID: u.ID,
- IsAdmin: u.IsAdmin,
- Epoch: u.TokenEpoch,
- Audience: audience,
- }
+ return token, err
}
func CreateToken(u *model.User) (string, error) {
- token, _, err := TokenAuth.Encode(userClaims(u, nil).ToMap())
+ claims := createBaseClaims()
+ claims[jwt.SubjectKey] = u.UserName
+ claims[jwt.IssuedAtKey] = time.Now().UTC().Unix()
+ claims["uid"] = u.ID
+ claims["adm"] = u.IsAdmin
+ token, _, err := TokenAuth.Encode(claims)
if err != nil {
return "", err
}
@@ -96,105 +88,55 @@ 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))
-}
+ claims, err := token.AsMap(context.Background())
+ if err != nil {
+ return "", err
+ }
+
+ claims[jwt.ExpirationKey] = time.Now().UTC().Add(conf.Server.SessionTimeout).Unix()
+ _, newToken, err := TokenAuth.Encode(claims)
-func TouchClaims(c Claims) (string, error) {
- c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
- _, newToken, err := TokenAuth.Encode(c.ToMap())
return newToken, err
}
-func Validate(tokenStr string) (Claims, error) {
+func Validate(tokenStr string) (map[string]interface{}, error) {
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
if err != nil {
- return Claims{}, err
+ return nil, err
}
- 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
+ return token.AsMap(context.Background())
}
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)
+ log.Debug(ctx, "Scanner: No admin user yet!", err)
} else {
- log.Error(ctx, "No admin user found!", err)
+ log.Error(ctx, "Scanner: No admin user found!", err)
}
- u = &model.User{IsAdmin: true, UserName: "admin"}
+ u = &model.User{}
}
ctx = request.WithUsername(ctx, u.UserName)
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
}
-// EncodeToken creates a signed JWT from an arbitrary claims map.
-// It sets the issuer claim automatically.
-func EncodeToken(claims map[string]any) (string, error) {
- claims[jwt.IssuerKey] = consts.JWTIssuer
- _, token, err := TokenAuth.Encode(claims)
- return token, err
-}
-
-// DecodeAndVerifyToken verifies a JWT string and returns the parsed token.
-func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
- return jwtauth.VerifyToken(TokenAuth, tokenStr)
-}
-
func getEncKey() []byte {
key := cmp.Or(
conf.Server.PasswordEncryptionKey,
diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go
index c86dcd08c..504e56a52 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() {
@@ -44,7 +45,7 @@ var _ = Describe("Auth", func() {
})
It("returns the claims from a valid JWT token", func() {
- claims := map[string]any{}
+ claims := map[string]interface{}{}
claims["iss"] = "issuer"
claims["iat"] = time.Now().Unix()
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
@@ -53,11 +54,11 @@ var _ = Describe("Auth", func() {
decodedClaims, err := auth.Validate(tokenStr)
Expect(err).NotTo(HaveOccurred())
- Expect(decodedClaims.Issuer).To(Equal("issuer"))
+ Expect(decodedClaims["iss"]).To(Equal("issuer"))
})
It("returns ErrExpired if the `exp` field is in the past", func() {
- claims := map[string]any{}
+ claims := map[string]interface{}{}
claims["iss"] = "issuer"
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
_, tokenStr, err := auth.TokenAuth.Encode(claims)
@@ -81,63 +82,18 @@ var _ = Describe("Auth", func() {
claims, err := auth.Validate(tokenStr)
Expect(err).NotTo(HaveOccurred())
- Expect(claims.Issuer).To(Equal(consts.JWTIssuer))
- Expect(claims.Subject).To(Equal("johndoe"))
- Expect(claims.UserID).To(Equal("123"))
- Expect(claims.IsAdmin).To(Equal(true))
- Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
- })
- })
-
- 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())
+ Expect(claims["iss"]).To(Equal(consts.JWTIssuer))
+ Expect(claims["sub"]).To(Equal("johndoe"))
+ Expect(claims["uid"]).To(Equal("123"))
+ Expect(claims["adm"]).To(Equal(true))
+ Expect(claims["exp"]).To(BeTemporally(">", time.Now()))
})
})
Describe("TouchToken", func() {
It("updates the expiration time", func() {
yesterday := time.Now().Add(-oneDay)
- claims := map[string]any{}
+ claims := map[string]interface{}{}
claims["iss"] = "issuer"
claims["exp"] = yesterday.Unix()
token, _, err := auth.TokenAuth.Encode(claims)
@@ -148,116 +104,8 @@ var _ = Describe("Auth", func() {
decodedClaims, err := auth.Validate(touched)
Expect(err).NotTo(HaveOccurred())
- 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())
+ exp := decodedClaims["exp"].(time.Time)
+ Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay))
})
})
})
diff --git a/core/auth/claims.go b/core/auth/claims.go
deleted file mode 100644
index 42f7e4f2f..000000000
--- a/core/auth/claims.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package auth
-
-import (
- "time"
-
- "github.com/lestrrat-go/jwx/v3/jwt"
-)
-
-// Claims represents the typed JWT claims used throughout Navidrome,
-// replacing the untyped map[string]any approach.
-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
- IssuedAt time.Time
- ExpiresAt time.Time
-
- // Custom claims
- UserID string // "uid"
- IsAdmin bool // "adm"
- ID string // "id" - artwork/mediafile ID
- 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().
-// Only non-zero fields are included.
-func (c Claims) ToMap() map[string]any {
- m := make(map[string]any)
- if c.Issuer != "" {
- m[jwt.IssuerKey] = c.Issuer
- }
- 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()
- }
- if !c.ExpiresAt.IsZero() {
- m[jwt.ExpirationKey] = c.ExpiresAt.UTC().Unix()
- }
- if c.UserID != "" {
- m["uid"] = c.UserID
- }
- if c.IsAdmin {
- m["adm"] = c.IsAdmin
- }
- if c.ID != "" {
- m["id"] = c.ID
- }
- if c.Format != "" {
- m["f"] = c.Format
- }
- if c.BitRate != 0 {
- m["b"] = c.BitRate
- }
- if c.ShareID != "" {
- m["sid"] = c.ShareID
- }
- if c.Epoch != 0 {
- m["ep"] = c.Epoch
- }
- return m
-}
-
-func (c Claims) WithExpiresAt(t time.Time) Claims {
- c.ExpiresAt = t
- return c
-}
-
-// ClaimsFromToken extracts Claims directly from a jwt.Token using token.Get().
-func ClaimsFromToken(token jwt.Token) Claims {
- var c Claims
- c.Issuer, _ = token.Issuer()
- 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 {
- c.UserID = uid
- }
- var adm bool
- if err := token.Get("adm", &adm); err == nil {
- c.IsAdmin = adm
- }
- var id string
- if err := token.Get("id", &id); err == nil {
- c.ID = id
- }
- var f string
- if err := token.Get("f", &f); err == nil {
- c.Format = f
- }
- c.BitRate = intClaim(token, "b")
- 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
deleted file mode 100644
index 69d054031..000000000
--- a/core/auth/claims_test.go
+++ /dev/null
@@ -1,148 +0,0 @@
-package auth_test
-
-import (
- "time"
-
- "github.com/go-chi/jwtauth/v5"
- "github.com/navidrome/navidrome/core/auth"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("Claims", func() {
- Describe("ToMap", func() {
- It("includes only non-zero fields", func() {
- c := auth.Claims{
- Issuer: "ND",
- Subject: "johndoe",
- UserID: "123",
- IsAdmin: true,
- }
- m := c.ToMap()
- Expect(m).To(HaveKeyWithValue("iss", "ND"))
- Expect(m).To(HaveKeyWithValue("sub", "johndoe"))
- Expect(m).To(HaveKeyWithValue("uid", "123"))
- Expect(m).To(HaveKeyWithValue("adm", true))
- Expect(m).NotTo(HaveKey("exp"))
- Expect(m).NotTo(HaveKey("iat"))
- Expect(m).NotTo(HaveKey("id"))
- Expect(m).NotTo(HaveKey("f"))
- Expect(m).NotTo(HaveKey("b"))
- Expect(m).NotTo(HaveKey("sid"))
- })
-
- It("includes expiration and issued-at when set", func() {
- now := time.Now()
- c := auth.Claims{
- IssuedAt: now,
- ExpiresAt: now.Add(time.Hour),
- }
- m := c.ToMap()
- Expect(m).To(HaveKey("iat"))
- Expect(m).To(HaveKey("exp"))
- })
-
- It("includes custom claims for public tokens", func() {
- c := auth.Claims{
- ID: "al-123",
- Format: "mp3",
- BitRate: 192,
- }
- m := c.ToMap()
- Expect(m).To(HaveKeyWithValue("id", "al-123"))
- Expect(m).To(HaveKeyWithValue("f", "mp3"))
- Expect(m).To(HaveKeyWithValue("b", 192))
- })
-
- It("includes share ID claim when set", func() {
- c := auth.Claims{ShareID: "abc1234567"}
- m := c.ToMap()
- Expect(m).To(HaveKeyWithValue("sid", "abc1234567"))
- })
- })
-
- Describe("ClaimsFromToken", func() {
- It("round-trips session claims through encode/decode", func() {
- tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
- now := time.Now().Truncate(time.Second)
- original := auth.Claims{
- Issuer: "ND",
- Subject: "johndoe",
- UserID: "123",
- IsAdmin: true,
- }
- m := original.ToMap()
- m["iat"] = now.UTC().Unix()
- token, _, err := tokenAuth.Encode(m)
- Expect(err).NotTo(HaveOccurred())
-
- c := auth.ClaimsFromToken(token)
- Expect(c.Issuer).To(Equal("ND"))
- Expect(c.Subject).To(Equal("johndoe"))
- Expect(c.UserID).To(Equal("123"))
- Expect(c.IsAdmin).To(BeTrue())
- Expect(c.IssuedAt.UTC()).To(Equal(now.UTC()))
- })
-
- It("round-trips public token claims through encode/decode", func() {
- tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
- original := auth.Claims{
- Issuer: "ND",
- ID: "al-456",
- Format: "opus",
- BitRate: 128,
- ShareID: "abc1234567",
- }
- token, _, err := tokenAuth.Encode(original.ToMap())
- Expect(err).NotTo(HaveOccurred())
-
- c := auth.ClaimsFromToken(token)
- Expect(c.Issuer).To(Equal("ND"))
- Expect(c.ID).To(Equal("al-456"))
- Expect(c.ShareID).To(Equal("abc1234567"))
- Expect(c.Format).To(Equal("opus"))
- Expect(c.BitRate).To(Equal(128))
- })
- })
-
- 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..c8dde12d9 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"
@@ -44,7 +43,7 @@ var _ = Describe("common.go", func() {
It("returns the absolute path when library exists", func() {
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..29975e5c5 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,8 +39,8 @@ 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) {
- argsSlice := make([]any, len(options))
+func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
+ argsSlice := make([]interface{}, 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)
@@ -93,27 +92,9 @@ func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFil
return args.Get(0).(*model.MediaFile), args.Error(1)
}
-// 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...)
-}
-
// 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) {
- argsSlice := make([]any, len(options))
+func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
+ argsSlice := make([]interface{}, len(options))
for i, v := range options {
argsSlice[i] = v
}
@@ -156,7 +137,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,8 +146,8 @@ 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) {
- argsSlice := make([]any, len(options))
+func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
+ argsSlice := make([]interface{}, len(options))
for i, v := range options {
argsSlice[i] = v
}
@@ -301,31 +282,3 @@ func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid stri
}
return nil, args.Error(1)
}
-
-func (m *mockAgents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
- args := m.Called(ctx, id, name, artist, mbid, count)
- if args.Get(0) != nil {
- return args.Get(0).([]agents.Song), args.Error(1)
- }
- return nil, args.Error(1)
-}
-
-func (m *mockAgents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
- args := m.Called(ctx, id, name, artist, mbid, count)
- if args.Get(0) != nil {
- return args.Get(0).([]agents.Song), args.Error(1)
- }
- return nil, args.Error(1)
-}
-
-func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) {
- args := m.Called(ctx, id, name, mbid, count)
- if args.Get(0) != nil {
- return args.Get(0).([]agents.Song), args.Error(1)
- }
- 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..8e9a458c1 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"
@@ -12,12 +12,15 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
- "github.com/navidrome/navidrome/core/matcher"
+ _ "github.com/navidrome/navidrome/core/agents/deezer"
+ _ "github.com/navidrome/navidrome/core/agents/lastfm"
+ _ "github.com/navidrome/navidrome/core/agents/listenbrainz"
+ _ "github.com/navidrome/navidrome/core/agents/spotify"
"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"
@@ -33,44 +36,27 @@ const (
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
+ ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, 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]
}
type auxAlbum struct {
model.Album
-}
-
-// Name returns the appropriate album name for external API calls
-// based on the DevPreserveUnicodeInExternalCalls configuration option
-func (a *auxAlbum) Name() string {
- if conf.Server.DevPreserveUnicodeInExternalCalls {
- return a.Album.Name
- }
- return str.Clear(a.Album.Name)
+ Name string
}
type auxArtist struct {
model.Artist
-}
-
-// Name returns the appropriate artist name for external API calls
-// based on the DevPreserveUnicodeInExternalCalls configuration option
-func (a *auxArtist) Name() string {
- if conf.Server.DevPreserveUnicodeInExternalCalls {
- return a.Artist.Name
- }
- return str.Clear(a.Artist.Name)
+ Name string
}
type Agents interface {
@@ -82,24 +68,17 @@ type Agents interface {
agents.ArtistSimilarRetriever
agents.ArtistTopSongsRetriever
agents.ArtistURLRetriever
- agents.SimilarSongsByTrackRetriever
- agents.SimilarSongsByAlbumRetriever
- 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) Provider {
+ e := &provider{ds: ds, ag: agents}
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
+ var entity interface{}
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return auxAlbum{}, err
@@ -109,6 +88,7 @@ func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
switch v := entity.(type) {
case *model.Album:
album.Album = *v
+ album.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getAlbum(ctx, v.AlbumID)
default:
@@ -126,9 +106,8 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
}
updatedAt := V(album.ExternalInfoUpdatedAt)
- albumName := album.Name()
if updatedAt.IsZero() {
- log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName)
+ log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
album, err = e.populateAlbumInfo(ctx, album)
if err != nil {
return nil, err
@@ -137,7 +116,7 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
// If info is expired, trigger a populateAlbumInfo in the background
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
- log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
+ log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
e.albumQueue.enqueue(&album)
}
@@ -146,26 +125,24 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
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) {
+ info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
+ if errors.Is(err, agents.ErrNotFound) {
return album, nil
}
if err != nil {
- log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist,
+ log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
"elapsed", time.Since(start), err)
return album, err
}
- album.ExternalInfoUpdatedAt = new(time.Now())
+ album.ExternalInfoUpdatedAt = P(time.Now())
album.ExternalUrl = info.URL
if info.Description != "" {
album.Description = info.Description
}
- images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
+ images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
if err == nil && len(images) > 0 {
sort.Slice(images, func(i, j int) bool {
return images[i].Size > images[j].Size
@@ -182,20 +159,19 @@ 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,
+ log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name,
"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
}
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) {
- var entity any
+ var entity interface{}
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return auxArtist{}, err
@@ -205,6 +181,7 @@ func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error)
switch v := entity.(type) {
case *model.Artist:
artist.Artist = *v
+ artist.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getArtist(ctx, v.ArtistID)
case *model.Album:
@@ -233,9 +210,8 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If we don't have any info, retrieves it now
updatedAt := V(artist.ExternalInfoUpdatedAt)
- artistName := artist.Name()
if updatedAt.IsZero() {
- log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName)
+ log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
artist, err = e.populateArtistInfo(ctx, artist)
if err != nil {
return auxArtist{}, err
@@ -244,7 +220,7 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If info is expired, trigger a populateArtistInfo in the background
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
- log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
+ log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
e.artistQueue.enqueue(&artist)
}
return artist, nil
@@ -253,100 +229,165 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
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
+ mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name)
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.callGetSimilar(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())
+ log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, 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,
+ log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
"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) ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) {
+ artist, err := e.getArtist(ctx, id)
+ if err != nil {
+ return nil, err
}
+
+ e.callGetSimilar(ctx, e.ag, &artist, 15, false)
+ if utils.IsCtxDone(ctx) {
+ log.Warn(ctx, "ArtistRadio 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, "ArtistRadio call canceled", ctx.Err())
+ return ctx.Err()
+ }
+
+ topCount := max(count, 20)
+ topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, 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) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) {
- artist, err := e.findArtist(ctx, artistName, id)
+func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, 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
- }
-
- log.Error(ctx, "Failure occurred when trying to fetch artist", "name", artistName, "id", id, err)
return nil, err
}
+ 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()
+ 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
+ }
+
+ images, err := e.ag.GetAlbumImages(ctx, album.Name, 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", album.Name, "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", album.Name, "artist", album.AlbumArtist, err)
+ }
+ return nil, err
+ }
+
+ if len(images) == 0 {
+ log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", album.Name, "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)
@@ -360,80 +401,144 @@ func (e *provider) TopSongs(ctx context.Context, artistName, id string, count in
}
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
- artistName := artist.Name()
- songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count)
+ songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
if err != nil {
- return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
+ return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artist.Name, 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.
- 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
- }
- }
+ mbidMatches, err := e.loadTracksByMBID(ctx, songs)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
+ }
+ titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, mbidMatches)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
- mfs, err := e.matcher.MatchSongs(ctx, songs, count)
- if err != nil {
- return nil, err
- }
+ log.Trace(ctx, "Top Songs loaded", "name", artist.Name, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
+ mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
if len(mfs) == 0 {
- log.Debug(ctx, "No matching top songs found", "name", artistName)
+ log.Debug(ctx, "No matching top songs found", "name", artist.Name)
} else {
- log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs))
+ log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
}
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
+func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
+ var mbids []string
+ for _, s := range songs {
+ if s.MBID != "" {
+ mbids = append(mbids, s.MBID)
+ }
}
- return nil
+ matches := map[string]model.MediaFile{}
+ if len(mbids) == 0 {
+ return matches, nil
+ }
+ res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Eq{"mbz_recording_id": mbids},
+ squirrel.Eq{"missing": false},
+ },
+ })
+ if err != nil {
+ return matches, err
+ }
+ for _, mf := range res {
+ if id := mf.MbzRecordingID; id != "" {
+ if _, ok := matches[id]; !ok {
+ matches[id] = mf
+ }
+ }
+ }
+ return matches, nil
}
-func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) error {
- artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
+func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
+ titleMap := map[string]string{}
+ for _, s := range songs {
+ if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
+ continue
+ }
+ sanitized := str.SanitizeFieldForSorting(s.Name)
+ titleMap[sanitized] = s.Name
+ }
+ matches := map[string]model.MediaFile{}
+ if len(titleMap) == 0 {
+ return matches, nil
+ }
+ titleFilters := squirrel.Or{}
+ for sanitized := range titleMap {
+ titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized})
+ }
+
+ res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Or{
+ squirrel.Eq{"artist_id": artist.ID},
+ squirrel.Eq{"album_artist_id": artist.ID},
+ },
+ titleFilters,
+ squirrel.Eq{"missing": false},
+ },
+ Sort: "starred desc, rating desc, year asc, compilation asc ",
+ })
if err != nil {
- return err
+ return matches, err
+ }
+ for _, mf := range res {
+ sanitized := str.SanitizeFieldForSorting(mf.Title)
+ if _, ok := matches[sanitized]; !ok {
+ matches[sanitized] = mf
+ }
+ }
+ return matches, nil
+}
+
+func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
+ var mfs model.MediaFiles
+ for _, t := range songs {
+ if len(mfs) == count {
+ break
+ }
+ if t.MBID != "" {
+ if mf, ok := byMBID[t.MBID]; ok {
+ mfs = append(mfs, mf)
+ continue
+ }
+ }
+ if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok {
+ mfs = append(mfs, mf)
+ }
+ }
+ return mfs
+}
+
+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
}
artist.ExternalUrl = artisURL
- return nil
}
-func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) error {
- bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
+func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
+ bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(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,75 +551,57 @@ 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 {
- artistName := artist.Name()
- similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
+func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
+ limit int, includeNotPresent bool) {
+ similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, 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))
+ log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "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) {
var result model.Artists
var notPresent []string
- // Load artists by ID (highest priority)
- idMatches, err := e.loadArtistsByID(ctx, similar)
+ artistNames := slice.Map(similar, func(artist agents.Artist) string { return artist.Name })
+
+ // Query all artists at once
+ clauses := slice.Map(artistNames, func(name string) squirrel.Sqlizer {
+ return squirrel.Like{"artist.name": name}
+ })
+ artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.Or(clauses),
+ })
if err != nil {
return nil, err
}
- // Load artists by MBID (second priority)
- mbidMatches, err := e.loadArtistsByMBID(ctx, similar, idMatches)
- if err != nil {
- return nil, err
- }
-
- // Load artists by name (lowest priority, fallback)
- nameMatches, err := e.loadArtistsByName(ctx, similar, idMatches, mbidMatches)
- if err != nil {
- return nil, err
+ // Create a map for quick lookup
+ artistMap := make(map[string]model.Artist)
+ for _, artist := range artists {
+ artistMap[artist.Name] = artist
}
count := 0
- // Process the similar artists using priority: ID → MBID → Name
+ // Process the similar artists
for _, s := range similar {
- if count >= limit {
- break
- }
- // Try ID match first
- if s.ID != "" {
- if artist, found := idMatches[s.ID]; found {
- result = append(result, artist)
- count++
- continue
- }
- }
- // Try MBID match second
- if s.MBID != "" {
- if artist, found := mbidMatches[s.MBID]; found {
- result = append(result, artist)
- count++
- continue
- }
- }
- // Fall back to name match
- if artist, found := nameMatches[s.Name]; found {
+ if artist, found := artistMap[s.Name]; found {
result = append(result, artist)
count++
+
+ if count >= limit {
+ break
+ }
} else {
notPresent = append(notPresent, s.Name)
}
@@ -537,114 +624,8 @@ func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artis
return result, nil
}
-func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist) (map[string]model.Artist, error) {
- var ids []string
- for _, s := range similar {
- if s.ID != "" {
- ids = append(ids, s.ID)
- }
- }
- matches := map[string]model.Artist{}
- if len(ids) == 0 {
- return matches, nil
- }
- res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
- Filters: squirrel.Eq{"artist.id": ids},
- })
- if err != nil {
- return matches, err
- }
- for _, a := range res {
- if _, ok := matches[a.ID]; !ok {
- matches[a.ID] = a
- }
- }
- return matches, nil
-}
-
-func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist) (map[string]model.Artist, error) {
- var mbids []string
- for _, s := range similar {
- // Skip if already matched by ID
- if s.ID != "" && idMatches[s.ID].ID != "" {
- continue
- }
- if s.MBID != "" {
- mbids = append(mbids, s.MBID)
- }
- }
- matches := map[string]model.Artist{}
- if len(mbids) == 0 {
- return matches, nil
- }
- res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
- Filters: squirrel.Eq{"mbz_artist_id": mbids},
- })
- if err != nil {
- return matches, err
- }
- for _, a := range res {
- if id := a.MbzArtistID; id != "" {
- if _, ok := matches[id]; !ok {
- matches[id] = a
- }
- }
- }
- return matches, nil
-}
-
-func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist, mbidMatches map[string]model.Artist) (map[string]model.Artist, error) {
- var names []string
- for _, s := range similar {
- // Skip if already matched by ID or MBID
- if s.ID != "" && idMatches[s.ID].ID != "" {
- continue
- }
- if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
- continue
- }
- names = append(names, s.Name)
- }
- matches := map[string]model.Artist{}
- if len(names) == 0 {
- return matches, nil
- }
- clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
- return squirrel.Like{"artist.name": name}
- })
- res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
- Filters: squirrel.Or(clauses),
- })
- if err != nil {
- return matches, err
- }
- for _, a := range res {
- if _, ok := matches[a.Name]; !ok {
- matches[a.Name] = a
- }
- }
- 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,
})
@@ -654,7 +635,11 @@ func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxA
if len(artists) == 0 {
return nil, model.ErrNotFound
}
- return &auxArtist{Artist: artists[0]}, nil
+ artist := &auxArtist{
+ Artist: artists[0],
+ Name: str.Clear(artists[0].Name),
+ }
+ return artist, nil
}
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
@@ -666,11 +651,11 @@ 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 {
- log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err)
+ log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name, err)
return err
}
diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go
new file mode 100644
index 000000000..9b682462d
--- /dev/null
+++ b/core/external/provider_albumimage_test.go
@@ -0,0 +1,301 @@
+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/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)
+
+ // 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)
+ })
+})
+
+// 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..96341836a
--- /dev/null
+++ b/core/external/provider_artistimage_test.go
@@ -0,0 +1,301 @@
+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/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)
+
+ // 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", "")
+ })
+})
+
+// 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_artistradio_test.go b/core/external/provider_artistradio_test.go
new file mode 100644
index 000000000..21ea07706
--- /dev/null
+++ b/core/external/provider_artistradio_test.go
@@ -0,0 +1,196 @@
+package external_test
+
+import (
+ "context"
+ "errors"
+
+ "github.com/navidrome/navidrome/core/agents"
+ . "github.com/navidrome/navidrome/core/external"
+ "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 - ArtistRadio", func() {
+ var ds model.DataStore
+ var provider Provider
+ var mockAgent *mockSimilarArtistAgent
+ var mockTopAgent agents.ArtistTopSongsRetriever
+ var mockSimilarAgent agents.ArtistSimilarRetriever
+ var agentsCombined Agents
+ var artistRepo *mockArtistRepo
+ var mediaFileRepo *mockMediaFileRepo
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = GinkgoT().Context()
+
+ artistRepo = newMockArtistRepo()
+ mediaFileRepo = newMockMediaFileRepo()
+
+ ds = &tests.MockDataStore{
+ MockedArtist: artistRepo,
+ MockedMediaFile: mediaFileRepo,
+ }
+
+ mockAgent = &mockSimilarArtistAgent{}
+ mockTopAgent = mockAgent
+ mockSimilarAgent = mockAgent
+
+ agentsCombined = &mockAgents{
+ topSongsAgent: mockTopAgent,
+ similarAgent: mockSimilarAgent,
+ }
+
+ provider = NewProvider(ds, agentsCombined)
+ })
+
+ It("returns similar songs from main artist and similar artists", func() {
+ artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
+ similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
+ song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
+ song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
+
+ 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).Once()
+
+ similarAgentsResp := []agents.Artist{
+ {Name: "Similar Artist", MBID: "similar-mbid"},
+ }
+ mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
+ Return(similarAgentsResp, nil).Once()
+
+ artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ return opt.Max == 0 && opt.Filters != nil
+ })).Return(model.Artists{similarArtist}, nil).Once()
+
+ mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
+ Return([]agents.Song{
+ {Name: "Song One", MBID: "mbid-1"},
+ {Name: "Song Two", MBID: "mbid-2"},
+ }, nil).Once()
+
+ mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
+ Return([]agents.Song{
+ {Name: "Song Three", MBID: "mbid-3"},
+ }, nil).Once()
+
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
+
+ songs, err := provider.ArtistRadio(ctx, "artist-1", 3)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(3))
+ for _, song := range songs {
+ Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
+ }
+ })
+
+ 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)
+
+ artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
+ return opt.Max == 1 && opt.Filters != nil
+ })).Return(model.Artists{}, nil).Maybe()
+
+ songs, err := provider.ArtistRadio(ctx, "artist-unknown-artist", 5)
+
+ Expect(err).To(Equal(model.ErrNotFound))
+ Expect(songs).To(BeNil())
+ })
+
+ It("returns songs from main artist when GetSimilarArtists returns error", func() {
+ artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
+
+ artistRepo.On("Get", "artist-1").Return(&artist1, 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()
+
+ mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
+ Return(nil, errors.New("error getting similar artists")).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 One", "", mock.Anything).
+ Return([]agents.Song{
+ {Name: "Song One", MBID: "mbid-1"},
+ }, nil).Once()
+
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
+
+ songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(1))
+ Expect(songs[0].ID).To(Equal("song-1"))
+ })
+
+ It("returns empty list when GetArtistTopSongs returns error", func() {
+ artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
+
+ artistRepo.On("Get", "artist-1").Return(&artist1, 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()
+
+ mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 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 One", "", mock.Anything).
+ Return(nil, errors.New("error getting top songs")).Once()
+
+ songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(BeEmpty())
+ })
+
+ It("respects count parameter", func() {
+ artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
+ song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
+ song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
+
+ artistRepo.On("Get", "artist-1").Return(&artist1, 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()
+
+ mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 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 One", "", mock.Anything).
+ Return([]agents.Song{
+ {Name: "Song One", MBID: "mbid-1"},
+ {Name: "Song Two", MBID: "mbid-2"},
+ }, nil).Once()
+
+ mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
+
+ songs, err := provider.ArtistRadio(ctx, "artist-1", 1)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(songs).To(HaveLen(1))
+ Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
+ })
+})
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
deleted file mode 100644
index 3944edb68..000000000
--- a/core/external/provider_similarsongs_test.go
+++ /dev/null
@@ -1,1073 +0,0 @@
-package external_test
-
-import (
- "context"
- "errors"
- "math"
- "slices"
- "strings"
-
- "github.com/Masterminds/squirrel"
- "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 - SimilarSongs", func() {
- var ds model.DataStore
- var provider Provider
- var mockAgent *mockSimilarArtistAgent
- var mockTopAgent agents.ArtistTopSongsRetriever
- var mockSimilarAgent agents.ArtistSimilarRetriever
- var agentsCombined *mockAgents
- 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() {
- ctx = GinkgoT().Context()
-
- 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{}
- mockTopAgent = mockAgent
- mockSimilarAgent = mockAgent
-
- agentsCombined = &mockAgents{
- topSongsAgent: mockTopAgent,
- similarAgent: mockSimilarAgent,
- }
-
- provider = NewProvider(ds, agentsCombined, matcher.New(ds), &fakeBroker{})
- })
-
- // 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}},
- }
-
- // 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).
- Return([]agents.Song{
- {Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "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.
- mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
- _, ok := opt.Filters.(squirrel.Eq)
- return ok
- })).Return(model.MediaFiles{}, nil).Maybe()
-
- // MBID phase: won't fire (empty MBID).
- mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
- and, ok := opt.Filters.(squirrel.And)
- if !ok || len(and) < 1 {
- return false
- }
- eq, hasEq := and[0].(squirrel.Eq)
- if !hasEq {
- return false
- }
- _, hasMBID := eq["mbz_recording_id"]
- return hasMBID
- })).Return(model.MediaFiles{}, nil).Maybe()
-
- // Matcher track-fetch: subquery returns the matched song with participants.
- mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
- and, ok := opt.Filters.(squirrel.And)
- if !ok {
- return false
- }
- for _, f := range and {
- sql, _, err := f.ToSql()
- if err == nil && strings.Contains(sql, "media_file_artists") {
- return true
- }
- }
- return false
- })).Return(model.MediaFiles{matchedSong}, nil).Maybe()
-
- songs, err := provider.SimilarSongs(ctx, "track-1", 1)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(songs).To(HaveLen(1))
- Expect(songs[0].ID).To(Equal("matched-1"))
- })
-
- It("falls back to artist-based algorithm when GetSimilarSongsByTrack returns empty", func() {
- track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"}
- 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", "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", "Track", "Artist", "", mock.Anything).
- Return([]agents.Song{}, nil).Once()
-
- // Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the mediafile
- // and recursively calls getArtist(v.ArtistID)
- 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()
-
- // 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, "track-1", 5)
-
- Expect(err).ToNot(HaveOccurred())
- 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() {
- It("calls GetSimilarSongsByAlbum and returns matched songs", func() {
- album := model.Album{ID: "album-1", Name: "Speak & Spell", AlbumArtist: "Depeche Mode", MbzAlbumID: "album-mbid"}
- matchedSong := model.MediaFile{ID: "matched-1", Title: "New Life", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
-
- // GetEntityByID 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", "Speak & Spell", "Depeche Mode", "album-mbid", 1).
- Return([]agents.Song{
- {Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
- }, nil).Once()
-
- // 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).Once()
-
- // Mock loadTracksByMBID - MBID match
- mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
- and, ok := opt.Filters.(squirrel.And)
- if !ok || len(and) < 1 {
- return false
- }
- _, hasEq := and[0].(squirrel.Eq)
- return hasEq
- })).Return(model.MediaFiles{matchedSong}, nil).Once()
-
- songs, err := provider.SimilarSongs(ctx, "album-1", 1)
-
- 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() {
- album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"}
- seed := model.MediaFile{ID: "seed-1", Title: "Seed", Artist: "Artist"}
-
- 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()
-
- // 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()
-
- 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"))
- })
- })
-
- Context("when ID is an Artist", func() {
- It("calls GetSimilarSongsByArtist and returns matched songs", func() {
- artist := model.Artist{ID: "artist-1", Name: "Depeche Mode", MbzArtistID: "artist-mbid"}
- 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).
- Return([]agents.Song{
- {Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
- }, nil).Once()
-
- // 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).Once()
-
- // Mock loadTracksByMBID - MBID match
- mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
- and, ok := opt.Filters.(squirrel.And)
- if !ok || len(and) < 1 {
- return false
- }
- _, hasEq := and[0].(squirrel.Eq)
- return hasEq
- })).Return(model.MediaFiles{matchedSong}, nil).Once()
-
- songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
-
- 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() {
- artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
- similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
- song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
- song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
- song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
-
- 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).Once()
-
- // New similar songs by artist returns ErrNotFound to trigger fallback
- agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
- Return(nil, agents.ErrNotFound).Once()
-
- similarAgentsResp := []agents.Artist{
- {Name: "Similar Artist", MBID: "similar-mbid"},
- }
- mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
- Return(similarAgentsResp, nil).Once()
-
- // Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
- // MBID lookup returns empty (no match)
- 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()
- // Name lookup returns the similar artist
- 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()
-
- mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
- Return([]agents.Song{
- {Name: "Song One", MBID: "mbid-1"},
- {Name: "Song Two", MBID: "mbid-2"},
- }, nil).Once()
-
- mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
- Return([]agents.Song{
- {Name: "Song Three", MBID: "mbid-3"},
- }, nil).Once()
-
- mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
- mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
-
- songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(songs).To(HaveLen(3))
- for _, song := range songs {
- Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
- }
- })
-
- 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)
- albumRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
-
- artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
- return opt.Max == 1 && opt.Filters != nil
- })).Return(model.Artists{}, nil).Maybe()
-
- songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
-
- Expect(err).To(Equal(model.ErrNotFound))
- Expect(songs).To(BeNil())
- })
-
- It("returns songs from main artist when GetSimilarArtists returns error", func() {
- artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
- song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
-
- artistRepo.On("Get", "artist-1").Return(&artist1, 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()
-
- // New similar songs by artist returns ErrNotFound to trigger fallback
- agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
- Return(nil, agents.ErrNotFound).Once()
-
- mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
- Return(nil, errors.New("error getting similar artists")).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 One", "", mock.Anything).
- Return([]agents.Song{
- {Name: "Song One", MBID: "mbid-1"},
- }, nil).Once()
-
- mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
-
- songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(songs).To(HaveLen(1))
- Expect(songs[0].ID).To(Equal("song-1"))
- })
-
- It("returns empty list when GetArtistTopSongs returns error", func() {
- artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
-
- artistRepo.On("Get", "artist-1").Return(&artist1, 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()
-
- // New similar songs by artist returns ErrNotFound to trigger fallback
- agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
- Return(nil, agents.ErrNotFound).Once()
-
- mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 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 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())
- Expect(songs).To(BeEmpty())
- })
-
- It("respects count parameter", func() {
- artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
- song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
- song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
-
- artistRepo.On("Get", "artist-1").Return(&artist1, 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()
-
- // New similar songs by artist returns ErrNotFound to trigger fallback
- agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
- Return(nil, agents.ErrNotFound).Once()
-
- mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 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 One", "", mock.Anything).
- Return([]agents.Song{
- {Name: "Song One", MBID: "mbid-1"},
- {Name: "Song Two", MBID: "mbid-2"},
- }, nil).Once()
-
- mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
-
- songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(songs).To(HaveLen(1))
- Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
- })
-})
diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go
index 5d35ba914..5a5a25714 100644
--- a/core/external/provider_topsongs_test.go
+++ b/core/external/provider_topsongs_test.go
@@ -4,13 +4,11 @@ import (
"context"
"errors"
- _ "github.com/navidrome/navidrome/adapters/lastfm"
- _ "github.com/navidrome/navidrome/adapters/listenbrainz"
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
+ _ "github.com/navidrome/navidrome/core/agents/lastfm"
+ _ "github.com/navidrome/navidrome/core/agents/listenbrainz"
+ _ "github.com/navidrome/navidrome/core/agents/spotify"
. "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"
@@ -28,10 +26,6 @@ var _ = Describe("Provider - TopSongs", func() {
)
BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- // Disable fuzzy matching for these tests to avoid unexpected GetAll calls
- conf.Server.Matcher.FuzzyThreshold = 100
-
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo() // Use helper mock
@@ -45,7 +39,7 @@ var _ = Describe("Provider - TopSongs", func() {
ag = new(mockAgents)
- p = NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
+ p = NewProvider(ds, ag)
})
It("returns top songs for a known artist", func() {
@@ -65,7 +59,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 +70,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 +91,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 +107,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 +115,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 +128,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 +142,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 +153,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 +177,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 +189,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 +197,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 +218,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 +230,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,63 +262,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)
-
- 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("matches songs by ID first when agent provides IDs", 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()
-
- // Mock agent response with IDs provided (highest priority matching)
- // Note: Songs have no MBID to ensure only ID matching is used
- agentSongs := []agents.Song{
- {ID: "song-1", Name: "Song One"},
- {ID: "song-2", Name: "Song Two"},
- }
- ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
-
- // Mock ID lookup (first query - should match both songs directly)
- song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
- 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)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(songs).To(HaveLen(2))
- Expect(songs[0].ID).To(Equal("song-1"))
- Expect(songs[1].ID).To(Equal("song-2"))
- artistRepo.AssertExpectations(GinkgoT())
- ag.AssertExpectations(GinkgoT())
- mediaFileRepo.AssertExpectations(GinkgoT())
- })
-
- It("falls back to MBID when ID is not found", 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()
-
- // Mock agent response with ID that won't be found, but MBID that will
- agentSongs := []agents.Song{
- {ID: "non-existent-id", Name: "Song One", MBID: "mbid-song-1"},
- }
- ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
-
- // Mock ID lookup - returns empty (ID not found)
- mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once()
- // Mock MBID lookup - finds the song
- 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..5f5d41a87 100644
--- a/core/external/provider_updatealbuminfo_test.go
+++ b/core/external/provider_updatealbuminfo_test.go
@@ -8,10 +8,10 @@ import (
"github.com/navidrome/navidrome/conf"
"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/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@@ -34,8 +34,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)
+ mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
})
@@ -89,7 +89,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 +112,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 +164,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..9b1e8d866 100644
--- a/core/external/provider_updateartistinfo_test.go
+++ b/core/external/provider_updateartistinfo_test.go
@@ -9,10 +9,10 @@ import (
"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/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@@ -37,8 +37,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)
+ mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
})
It("returns error when artist is not found", func() {
@@ -104,48 +104,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",
- Name: "Encoded Bio Artist",
- }
- mockArtistRepo.SetData(model.Artists{*originalArtist})
-
- expectedMBID := "mbid-encoded-bio"
- expectedBio := "R&B"
-
- ag.On("GetArtistMBID", ctx, "ar-encoded-bio", "Encoded Bio Artist").Return(expectedMBID, nil).Once()
- ag.On("GetArtistImages", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(nil, nil).Maybe()
- ag.On("GetArtistBiography", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(expectedBio, nil).Once()
- ag.On("GetArtistURL", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return("", nil).Maybe()
- ag.On("GetSimilarArtists", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID, 100).Return(nil, nil).Maybe()
-
- updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-encoded-bio", 10, false)
-
- Expect(err).NotTo(HaveOccurred())
- Expect(updatedArtist).NotTo(BeNil())
- Expect(updatedArtist.Biography).To(Equal("R&B"))
- })
-
It("returns cached info when artist exists and info is not expired", func() {
now := time.Now()
originalArtist := &model.Artist{
@@ -155,7 +113,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 +150,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 +181,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"},
@@ -268,88 +226,4 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
ag.AssertExpectations(GinkgoT())
})
-
- It("matches similar artists by ID first when agent provides IDs", func() {
- originalArtist := &model.Artist{
- ID: "ar-id-match",
- Name: "ID Match Artist",
- }
- similarByID := model.Artist{ID: "ar-similar-by-id", Name: "Similar By ID", MbzArtistID: "mbid-similar"}
- mockArtistRepo.SetData(model.Artists{*originalArtist, similarByID})
-
- // Agent returns similar artist with ID (highest priority matching)
- rawSimilar := []agents.Artist{
- {ID: "ar-similar-by-id", Name: "Different Name", MBID: "different-mbid"},
- }
-
- ag.On("GetArtistMBID", ctx, "ar-id-match", "ID Match Artist").Return("", nil).Once()
- ag.On("GetArtistImages", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return(nil, nil).Maybe()
- ag.On("GetArtistBiography", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe()
- ag.On("GetArtistURL", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe()
- ag.On("GetSimilarArtists", ctx, "ar-id-match", "ID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
-
- updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-id-match", 10, false)
-
- Expect(err).NotTo(HaveOccurred())
- Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
- // Should match by ID, not by name or MBID
- Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-id"))
- Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By ID"))
- })
-
- It("matches similar artists by MBID when ID is empty", func() {
- originalArtist := &model.Artist{
- ID: "ar-mbid-match",
- Name: "MBID Match Artist",
- }
- similarByMBID := model.Artist{ID: "ar-similar-by-mbid", Name: "Similar By MBID", MbzArtistID: "mbid-similar"}
- mockArtistRepo.SetData(model.Artists{*originalArtist, similarByMBID})
-
- // Agent returns similar artist with only MBID (no ID)
- rawSimilar := []agents.Artist{
- {Name: "Different Name", MBID: "mbid-similar"},
- }
-
- ag.On("GetArtistMBID", ctx, "ar-mbid-match", "MBID Match Artist").Return("", nil).Once()
- ag.On("GetArtistImages", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return(nil, nil).Maybe()
- ag.On("GetArtistBiography", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe()
- ag.On("GetArtistURL", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe()
- ag.On("GetSimilarArtists", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
-
- updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-mbid-match", 10, false)
-
- Expect(err).NotTo(HaveOccurred())
- Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
- // Should match by MBID since ID was empty
- Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-mbid"))
- Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By MBID"))
- })
-
- It("falls back to name matching when ID and MBID don't match", func() {
- originalArtist := &model.Artist{
- ID: "ar-name-match",
- Name: "Name Match Artist",
- }
- similarByName := model.Artist{ID: "ar-similar-by-name", Name: "Similar By Name"}
- mockArtistRepo.SetData(model.Artists{*originalArtist, similarByName})
-
- // Agent returns similar artist with non-matching ID and MBID
- rawSimilar := []agents.Artist{
- {ID: "non-existent-id", Name: "Similar By Name", MBID: "non-existent-mbid"},
- }
-
- ag.On("GetArtistMBID", ctx, "ar-name-match", "Name Match Artist").Return("", nil).Once()
- ag.On("GetArtistImages", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return(nil, nil).Maybe()
- ag.On("GetArtistBiography", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe()
- ag.On("GetArtistURL", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe()
- ag.On("GetSimilarArtists", ctx, "ar-name-match", "Name Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
-
- updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-name-match", 10, false)
-
- Expect(err).NotTo(HaveOccurred())
- Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
- // Should fall back to name matching since ID and MBID didn't match
- Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-name"))
- Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By Name"))
- })
})
diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go
index af59178af..2e0d5a4b7 100644
--- a/core/ffmpeg/ffmpeg.go
+++ b/core/ffmpeg/ffmpeg.go
@@ -1,58 +1,26 @@
package ffmpeg
import (
- "bytes"
"context"
- "encoding/json"
"errors"
"fmt"
"io"
- "io/fs"
"os"
"os/exec"
- "path/filepath"
- "slices"
"strconv"
"strings"
"sync"
- "time"
"github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
)
-// TranscodeOptions contains all parameters for a transcoding operation.
-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.
-}
-
-// AudioProbeResult contains authoritative audio stream properties from ffprobe.
-type AudioProbeResult struct {
- Codec string `json:"codec"`
- Profile string `json:"profile,omitempty"`
- BitRate int `json:"bitRate"`
- SampleRate int `json:"sampleRate"`
- BitDepth int `json:"bitDepth"`
- Channels int `json:"channels"`
-}
-
type FFmpeg interface {
- Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
+ Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (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)
- ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
+ Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error)
IsAvailable() bool
- IsProbeAvailable() bool
Version() string
}
@@ -60,75 +28,30 @@ func New() FFmpeg {
return &ffmpeg{}
}
-// ErrAnimatedWebPUnsupported is returned by ConvertAnimatedImage when the
-// ffmpeg binary lacks the libwebp_anim encoder. Callers can use errors.Is to
-// detect this specific case and fall back to static resize.
-var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder — install an ffmpeg build with libwebp")
-
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"
+ extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
+ probeCmd = "ffmpeg %s -f ffmetadata"
)
type ffmpeg struct{}
-func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
+func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
- if err := fileExists(opts.FilePath); err != nil {
+ // First make sure the file exists
+ if err := fileExists(path); err != nil {
return nil, err
}
- var args []string
- if isDefaultCommand(opts.Format, opts.Command) {
- args = buildDynamicArgs(opts)
- } 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
-}
-
-func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
- cmdPath, err := ffmpegCmd()
- if err != nil {
- return nil, err
- }
- if !animWebP.has(cmdPath, "libwebp_anim") {
- return nil, ErrAnimatedWebPUnsupported
- }
-
- args := []string{cmdPath, "-i", "pipe:0"}
- if maxSize > 0 {
- vf := fmt.Sprintf("scale='min(%d,iw)':'min(%d,ih)':force_original_aspect_ratio=decrease", maxSize, maxSize)
- args = append(args, "-vf", vf)
- }
- args = append(args, "-loop", "0", "-c:v", "libwebp_anim",
- "-quality", strconv.Itoa(quality), "-f", "webp", "-")
-
- return e.start(ctx, args, reader)
-}
-
-// parseEncodersOutput scans the stdout of `ffmpeg -encoders` for a whole-word
-// match of encoder name. The output has rows like " V....D libwebp_anim ..."
-// where the name is the 2nd whitespace-separated field.
-func parseEncodersOutput(out []byte, name string) bool {
- for line := range strings.SplitSeq(string(out), "\n") {
- fields := strings.Fields(line)
- if len(fields) >= 2 && fields[1] == name {
- return true
- }
- }
- return false
+ args := createFFmpegCommand(command, path, maxBitRate, offset)
+ return e.start(ctx, args)
}
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
+ // First make sure the file exists
if err := fileExists(path); err != nil {
return nil, err
}
@@ -147,153 +70,15 @@ func fileExists(path string) error {
return nil
}
-func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
+func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
if _, err := ffmpegCmd(); err != nil {
- return nil, err
+ return "", err
}
- if err := fileExists(filePath); err != nil {
- return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err),
- NotFound: errors.Is(err, fs.ErrNotExist), err: err}
- }
- args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0)
- log.Trace(ctx, "Executing ffprobe command", "args", args)
+ args := createProbeCommand(probeCmd, files)
+ log.Trace(ctx, "Executing ffmpeg 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}
- }
- result, err := parseProbeOutput(output)
- if err != nil {
- return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err}
- }
- return result, nil
-}
-
-// ProbeError reports an ffprobe failure. Reason is a path-free message safe to
-// expose to clients; the wrapped cause carries the full detail for logging.
-// NotFound marks the media file itself as missing — a launch failure of a
-// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer
-// it from the error chain.
-type ProbeError struct {
- Path string
- Reason string
- NotFound bool
- err error
-}
-
-func (e *ProbeError) Error() string {
- if e.err == nil {
- return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason)
- }
- return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err))
-}
-
-// Unwrap exposes the underlying cause so callers can test it with errors.Is
-// (e.g. fs.ErrNotExist to detect a missing file).
-func (e *ProbeError) Unwrap() error { return e.err }
-
-// SafeReason returns the path-free reason, safe to send to clients.
-func (e *ProbeError) SafeReason() string { return e.Reason }
-
-// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved
-// or unreadable file reads as "file not found" rather than a raw ffprobe message.
-func fileAccessReason(err error) string {
- switch {
- case errors.Is(err, fs.ErrNotExist):
- return "file not found"
- case errors.Is(err, fs.ErrPermission):
- return "permission denied"
- default:
- return "file not accessible"
- }
-}
-
-// probeDetail returns the full diagnostic for logging (may contain paths):
-// ffprobe's stderr when present, otherwise the raw error text.
-func probeDetail(err error) string {
- if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 {
- return strings.TrimSpace(string(exitErr.Stderr))
- }
- return err.Error()
-}
-
-// probeClientReason returns a path-free reason for an ffprobe execution failure:
-// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe
-// couldn't run at all (its launch error may embed the binary path).
-func probeClientReason(err error, path string) string {
- exitErr, ok := errors.AsType[*exec.ExitError](err)
- if !ok || len(exitErr.Stderr) == 0 {
- return "could not read file"
- }
- return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file"))
-}
-
-type probeOutput struct {
- Streams []probeStream `json:"streams"`
- Format probeFormat `json:"format"`
-}
-
-type probeFormat struct {
- BitRate string `json:"bit_rate"`
-}
-
-type probeStream struct {
- CodecName string `json:"codec_name"`
- CodecType string `json:"codec_type"`
- Profile string `json:"profile"`
- SampleRate string `json:"sample_rate"`
- BitRate string `json:"bit_rate"`
- Channels int `json:"channels"`
- BitsPerSample int `json:"bits_per_sample"`
- BitsPerRawSample string `json:"bits_per_raw_sample"`
-}
-
-func parseProbeOutput(data []byte) (*AudioProbeResult, error) {
- var output probeOutput
- if err := json.Unmarshal(data, &output); err != nil {
- return nil, fmt.Errorf("parsing ffprobe output: %w", err)
- }
-
- for _, s := range output.Streams {
- if s.CodecType != "audio" {
- continue
- }
- bitDepth := s.BitsPerSample
- if bitDepth == 0 && s.BitsPerRawSample != "" {
- bitDepth, _ = strconv.Atoi(s.BitsPerRawSample)
- }
- result := &AudioProbeResult{
- Codec: s.CodecName,
- Channels: s.Channels,
- BitDepth: bitDepth,
- }
-
- // Profile: "unknown" → empty
- if s.Profile != "" && !strings.EqualFold(s.Profile, "unknown") {
- result.Profile = s.Profile
- }
-
- // Sample rate: string → int
- if s.SampleRate != "" {
- result.SampleRate, _ = strconv.Atoi(s.SampleRate)
- }
-
- // Bit rate: bps string → kbps int
- if s.BitRate != "" {
- bps, _ := strconv.Atoi(s.BitRate)
- result.BitRate = bps / 1000
- }
-
- // Fallback to format-level bit_rate (needed for FLAC, Opus, etc.)
- if result.BitRate == 0 && output.Format.BitRate != "" {
- bps, _ := strconv.Atoi(output.Format.BitRate)
- result.BitRate = bps / 1000
- }
-
- return result, nil
- }
-
- return nil, fmt.Errorf("no audio stream found in ffprobe output")
+ output, _ := cmd.CombinedOutput()
+ return string(output), nil
}
func (e *ffmpeg) CmdPath() (string, error) {
@@ -305,19 +90,6 @@ func (e *ffmpeg) IsAvailable() bool {
return err == nil
}
-func (e *ffmpeg) IsProbeAvailable() bool {
- if _, err := ffmpegCmd(); err != nil {
- return false
- }
- probeOnce.Do(func() {
- probePath := ffprobePath(ffmpegPath)
- if _, err := exec.LookPath(probePath); err == nil {
- probeAvail = true
- }
- })
- return probeAvail
-}
-
// Version executes ffmpeg -version and extracts the version from the output.
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
func (e *ffmpeg) Version() string {
@@ -336,14 +108,11 @@ func (e *ffmpeg) Version() string {
return parts[2]
}
-func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (io.ReadCloser, error) {
+func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
- if len(input) > 0 {
- j.input = input[0]
- }
j.PipeReader, j.out = io.Pipe()
- err := j.start(ctx)
+ err := j.start()
if err != nil {
return nil, err
}
@@ -353,25 +122,18 @@ func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (
type ffCmd struct {
*io.PipeReader
- out *io.PipeWriter
- args []string
- cmd *exec.Cmd
- input io.Reader // optional stdin source
- stderr *bytes.Buffer
+ out *io.PipeWriter
+ args []string
+ cmd *exec.Cmd
}
-func (j *ffCmd) start(ctx context.Context) error {
- cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
+func (j *ffCmd) start() error {
+ cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
- if j.input != nil {
- cmd.Stdin = j.input
- }
- j.stderr = &bytes.Buffer{}
- stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096}
if log.IsGreaterOrEqualTo(log.LevelTrace) {
- cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter)
+ cmd.Stderr = os.Stderr
} else {
- cmd.Stderr = stderrWriter
+ cmd.Stderr = io.Discard
}
j.cmd = cmd
@@ -383,12 +145,9 @@ 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 {
- 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
- }
- _ = j.out.CloseWithError(errors.New(errMsg))
+ 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))
}
@@ -397,180 +156,16 @@ func (j *ffCmd) wait() {
_ = j.out.Close()
}
-// limitedWriter wraps a bytes.Buffer and stops writing once the limit is reached.
-// Writes that would exceed the limit are silently discarded to prevent unbounded memory usage.
-type limitedWriter struct {
- buf *bytes.Buffer
- limit int
-}
-
-func (w *limitedWriter) Write(p []byte) (int, error) {
- n := len(p)
- remaining := w.limit - w.buf.Len()
- if remaining <= 0 {
- return n, nil // Discard but report success to avoid breaking the writer
- }
- if len(p) > remaining {
- p = p[:remaining]
- }
- w.buf.Write(p)
- return n, nil // Always report full write to avoid ErrShortWrite from io.MultiWriter
-}
-
-// formatCodecMap maps target format to ffmpeg codec flag.
-var formatCodecMap = map[string]string{
- "mp3": "libmp3lame",
- "opus": "libopus",
- "aac": "aac",
- "flac": "flac",
-}
-
-// formatOutputMap maps target format to ffmpeg output format flag (-f).
-var formatOutputMap = map[string]string{
- "mp3": "mp3",
- "opus": "opus",
- "aac": "adts",
- "flac": "flac",
-}
-
-// defaultCommands is used to detect whether a user has customized their transcoding command.
-var defaultCommands = func() map[string]string {
- m := make(map[string]string, len(consts.DefaultTranscodings))
- for _, t := range consts.DefaultTranscodings {
- m[t.TargetFormat] = t.Command
- }
- return m
-}()
-
-// isDefaultCommand returns true if the command matches the known default for this format.
-func isDefaultCommand(format, command string) bool {
- return defaultCommands[format] == command
-}
-
-// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats,
-// including all transcoding parameters (bitrate, sample rate, channels).
-func buildDynamicArgs(opts TranscodeOptions) []string {
- cmdPath, _ := ffmpegCmd()
- args := []string{cmdPath}
-
- if opts.Offset > 0 {
- args = append(args, "-ss", strconv.Itoa(opts.Offset))
- }
-
- args = append(args, "-i", opts.FilePath)
- args = append(args, "-map", "0:a:0")
-
- // Preserve source tags. -map_metadata 0 copies format-level tags (MP3/FLAC);
- // -map_metadata 0:s:a:0 copies tags from the first audio stream (OPUS/OGG).
- // Both are needed because the two source families store tags at different
- // levels. Targeting the audio stream explicitly (s:a:0 rather than s:0) avoids
- // pulling metadata from an embedded cover-art/video stream at index 0. Note:
- // adts (AAC) output cannot hold tags, so these are a no-op there.
- args = append(args, "-map_metadata", "0", "-map_metadata", "0:s:a:0")
-
- if codec, ok := formatCodecMap[opts.Format]; ok {
- args = append(args, "-c:a", codec)
- }
-
- if opts.BitRate > 0 {
- args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
- }
- args = injectDynamicAudioFlags(args, opts)
-
- args = append(args, "-v", "0")
-
- if outputFmt, ok := formatOutputMap[opts.Format]; ok {
- args = append(args, "-f", outputFmt)
- }
-
- args = append(args, "-")
- return args
-}
-
-// buildTemplateArgs handles user-customized command templates, with dynamic injection
-// of sample rate, channels, and bit depth when requested by the transcode decision.
-// Values in opts have already been clamped to codec limits upstream (see
-// core/stream/codec.go codecMax* helpers), so injecting them unconditionally is safe —
-// ffmpeg honors the last occurrence of a duplicate flag.
-func buildTemplateArgs(opts TranscodeOptions) []string {
- args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
- return injectDynamicAudioFlags(args, opts)
-}
-
-// injectDynamicAudioFlags appends -ar, -ac, and -sample_fmt flags based on opts.
-// Only passes -sample_fmt for lossless output formats where bit depth matters:
-// lossy codecs (mp3, aac, opus) handle sample format conversion internally, and
-// passing interleaved formats like "s16" causes silent failures.
-func injectDynamicAudioFlags(args []string, opts TranscodeOptions) []string {
- if opts.SampleRate > 0 {
- args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
- }
- if opts.Channels > 0 {
- args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels))
- }
- if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
- args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
- }
- return args
-}
-
-// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output).
-func injectBeforeOutput(args []string, flag, value string) []string {
- if len(args) > 0 && args[len(args)-1] == "-" {
- result := make([]string, 0, len(args)+2)
- result = append(result, args[:len(args)-1]...)
- result = append(result, flag, value, "-")
- return result
- }
- return append(args, flag, value)
-}
-
-// isLosslessOutputFormat returns true if the format is a lossless audio format
-// where preserving bit depth via -sample_fmt is meaningful.
-// Note: this covers only formats ffmpeg can produce as output. For the full set of
-// lossless formats used in transcoding decisions, see core/stream/codec.go:isLosslessFormat.
-func isLosslessOutputFormat(format string) bool {
- switch strings.ToLower(format) {
- case "flac", "alac", "wav", "aiff":
- return true
- }
- return false
-}
-
-// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
-// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
-// (ffmpeg packs 24-bit samples into 32-bit containers).
-func bitDepthToSampleFmt(bitDepth int) string {
- switch bitDepth {
- case 16:
- return "s16"
- case 32:
- return "s32"
- default:
- // 24-bit and other depths: use s32 (the next valid container size)
- return "s32"
- }
-}
-
// Path will always be an absolute path
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 +175,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()
@@ -587,20 +196,10 @@ func fixCmd(cmd string) []string {
if s == "ffmpeg" || s == "ffmpeg.exe" {
split[i] = cmdPath
}
- if s == "ffprobe" || s == "ffprobe.exe" {
- split[i] = ffprobePath(cmdPath)
- }
}
return split
}
-// ffprobePath derives the ffprobe binary path from the resolved ffmpeg path.
-func ffprobePath(ffmpegCmd string) string {
- dir := filepath.Dir(ffmpegCmd)
- base := filepath.Base(ffmpegCmd)
- return filepath.Join(dir, strings.Replace(base, "ffmpeg", "ffprobe", 1))
-}
-
func ffmpegCmd() (string, error) {
ffOnce.Do(func() {
if conf.Server.FFmpegPath != "" {
@@ -621,55 +220,9 @@ func ffmpegCmd() (string, error) {
return ffmpegPath, ffmpegErr
}
-type encoderProbeState uint8
-
-const (
- encoderProbeUnknown encoderProbeState = iota
- encoderProbeAvailable
- encoderProbeUnavailable
-)
-
-type encoderProbe struct {
- mu sync.Mutex
- state encoderProbeState
-}
-
-func (p *encoderProbe) has(cmdPath, encoder string) bool {
- p.mu.Lock()
- defer p.mu.Unlock()
-
- switch p.state {
- case encoderProbeAvailable:
- return true
- case encoderProbeUnavailable:
- return false
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- out, err := exec.CommandContext(ctx, cmdPath, "-hide_banner", "-encoders").Output() // #nosec
- if err != nil {
- log.Warn(ctx, "Could not probe ffmpeg encoders; will retry on next animated cover", err)
- return false
- }
-
- if parseEncodersOutput(out, encoder) {
- p.state = encoderProbeAvailable
- return true
- }
-
- p.state = encoderProbeUnavailable
- log.Warn(ctx, "ffmpeg has no libwebp_anim encoder; animated covers will be served as static images",
- "path", cmdPath, "hint", "install ffmpeg built with libwebp (e.g. `brew install ffmpeg@7`)")
- return false
-}
-
// These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead.
var (
ffOnce sync.Once
ffmpegPath string
ffmpegErr error
- probeOnce sync.Once
- probeAvail bool
- animWebP encoderProbe
)
diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go
index 46684fe14..7e67a2a6a 100644
--- a/core/ffmpeg/ffmpeg_test.go
+++ b/core/ffmpeg/ffmpeg_test.go
@@ -1,32 +1,16 @@
package ffmpeg
import (
- "context"
- "errors"
- "io"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "sync"
"testing"
- "time"
- "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestFFmpeg(t *testing.T) {
- // Inline test init to avoid import cycle with tests package
- //nolint:dogsled
- _, file, _, _ := runtime.Caller(0)
- appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
- confPath := filepath.Join(appPath, "tests", "navidrome-test.toml")
- _ = os.Chdir(appPath)
- conf.LoadFromFile(confPath)
+ tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "FFmpeg Suite")
@@ -49,808 +33,36 @@ 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", "-"}))
- })
- })
-
- 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())
- })
- 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())
- })
- 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())
- })
- 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())
- })
- It("returns false for a custom command", func() {
- Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
- })
- It("returns false for unknown format", func() {
- Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse())
- })
- })
-
- Describe("buildDynamicArgs", func() {
- It("builds mp3 args with bitrate, samplerate, and channels", func() {
- args := buildDynamicArgs(TranscodeOptions{
- Format: "mp3",
- FilePath: "/music/file.flac",
- BitRate: 256,
- SampleRate: 48000,
- Channels: 2,
- })
- 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",
- "-ac", "2",
- "-v", "0",
- "-f", "mp3",
- "-",
- }))
- })
-
- It("builds flac args without bitrate", func() {
- args := buildDynamicArgs(TranscodeOptions{
- Format: "flac",
- FilePath: "/music/file.dsf",
- SampleRate: 48000,
- })
- 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",
- "-f", "flac",
- "-",
- }))
- })
-
- It("builds opus args with bitrate only", func() {
- args := buildDynamicArgs(TranscodeOptions{
- Format: "opus",
- FilePath: "/music/file.flac",
- BitRate: 128,
- })
- 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",
- "-f", "opus",
- "-",
- }))
- })
-
- It("includes offset when specified", func() {
- args := buildDynamicArgs(TranscodeOptions{
- Format: "mp3",
- FilePath: "/music/file.mp3",
- BitRate: 192,
- Offset: 30,
- })
- Expect(args).To(Equal([]string{
- "ffmpeg",
- "-ss", "30",
- "-i", "/music/file.mp3",
- "-map", "0:a:0",
- "-map_metadata", "0", "-map_metadata", "0:s:a:0",
- "-c:a", "libmp3lame",
- "-b:a", "192k",
- "-v", "0",
- "-f", "mp3",
- "-",
- }))
- })
-
- It("builds aac args with ADTS output", func() {
- args := buildDynamicArgs(TranscodeOptions{
- Format: "aac",
- FilePath: "/music/file.flac",
- BitRate: 256,
- })
- 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",
- "-f", "adts",
- "-",
- }))
- })
-
- It("builds flac args with bit depth", func() {
- args := buildDynamicArgs(TranscodeOptions{
- Format: "flac",
- FilePath: "/music/file.dsf",
- BitDepth: 24,
- })
- 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",
- "-f", "flac",
- "-",
- }))
- })
-
- It("omits -sample_fmt when bit depth is 0", func() {
- args := buildDynamicArgs(TranscodeOptions{
- Format: "flac",
- FilePath: "/music/file.flac",
- BitDepth: 0,
- })
- Expect(args).ToNot(ContainElement("-sample_fmt"))
- })
-
- It("omits -sample_fmt when bit depth is too low (DSD)", func() {
- args := buildDynamicArgs(TranscodeOptions{
- Format: "flac",
- FilePath: "/music/file.dsf",
- BitDepth: 1,
- })
- Expect(args).ToNot(ContainElement("-sample_fmt"))
- })
-
- DescribeTable("omits -sample_fmt for lossy formats even when bit depth >= 16",
- func(format string, bitRate int) {
- args := buildDynamicArgs(TranscodeOptions{
- Format: format,
- FilePath: "/music/file.flac",
- BitRate: bitRate,
- BitDepth: 16,
- })
- Expect(args).ToNot(ContainElement("-sample_fmt"))
- },
- Entry("mp3", "mp3", 256),
- Entry("aac", "aac", 256),
- Entry("opus", "opus", 128),
- )
- })
-
- Describe("bitDepthToSampleFmt", func() {
- It("converts 16-bit", func() {
- Expect(bitDepthToSampleFmt(16)).To(Equal("s16"))
- })
- It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() {
- Expect(bitDepthToSampleFmt(24)).To(Equal("s32"))
- })
- It("converts 32-bit", func() {
- Expect(bitDepthToSampleFmt(32)).To(Equal("s32"))
- })
- })
-
- Describe("buildTemplateArgs", func() {
- It("injects -ar and -ac into custom template", func() {
- args := buildTemplateArgs(TranscodeOptions{
- Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
- FilePath: "/music/file.flac",
- BitRate: 192,
- SampleRate: 44100,
- Channels: 2,
- })
- Expect(args).To(Equal([]string{
- "ffmpeg", "-i", "/music/file.flac",
- "-b:a", "192k", "-v", "0", "-f", "mp3",
- "-ar", "44100", "-ac", "2",
- "-",
- }))
- })
-
- It("injects only -ar when channels is 0", func() {
- args := buildTemplateArgs(TranscodeOptions{
- Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
- FilePath: "/music/file.flac",
- BitRate: 192,
- SampleRate: 48000,
- })
- Expect(args).To(Equal([]string{
- "ffmpeg", "-i", "/music/file.flac",
- "-b:a", "192k", "-v", "0", "-f", "mp3",
- "-ar", "48000",
- "-",
- }))
- })
-
- It("does not inject anything when sample rate and channels are 0", func() {
- args := buildTemplateArgs(TranscodeOptions{
- Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
- FilePath: "/music/file.flac",
- BitRate: 192,
- })
- Expect(args).To(Equal([]string{
- "ffmpeg", "-i", "/music/file.flac",
- "-b:a", "192k", "-v", "0", "-f", "mp3",
- "-",
- }))
- })
-
- It("injects -sample_fmt for lossless output format with bit depth", func() {
- args := buildTemplateArgs(TranscodeOptions{
- Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -",
- Format: "flac",
- FilePath: "/music/file.dsf",
- BitDepth: 24,
- })
- Expect(args).To(Equal([]string{
- "ffmpeg", "-i", "/music/file.dsf",
- "-v", "0", "-c:a", "flac", "-f", "flac",
- "-sample_fmt", "s32",
- "-",
- }))
- })
-
- It("does not inject -sample_fmt for lossy output format even with bit depth", func() {
- args := buildTemplateArgs(TranscodeOptions{
- Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
- Format: "mp3",
- FilePath: "/music/file.flac",
- BitRate: 192,
- BitDepth: 16,
- })
- Expect(args).To(Equal([]string{
- "ffmpeg", "-i", "/music/file.flac",
- "-b:a", "192k", "-v", "0", "-f", "mp3",
- "-",
- }))
- })
- })
-
- Describe("injectBeforeOutput", func() {
- It("inserts flag before trailing dash", func() {
- args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000")
- Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"}))
- })
-
- It("appends when no trailing dash", func() {
- args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000")
- Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"}))
- })
- })
-
- Describe("parseProbeOutput", func() {
- It("parses MP3 with embedded artwork (real ffprobe output)", func() {
- // Real: MP3 file with mjpeg artwork stream after audio
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"mp3","codec_long_name":"MP3 (MPEG audio layer 3)","codec_type":"audio",` +
- `"sample_fmt":"fltp","sample_rate":"44100","channels":2,"channel_layout":"stereo",` +
- `"bits_per_sample":0,"bit_rate":"198314","tags":{"encoder":"LAME3.99r"}},` +
- `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline","width":400,"height":400}]}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.Codec).To(Equal("mp3"))
- Expect(result.Profile).To(BeEmpty()) // MP3 has no profile field
- Expect(result.SampleRate).To(Equal(44100))
- Expect(result.Channels).To(Equal(2))
- Expect(result.BitRate).To(Equal(198)) // 198314 bps -> 198 kbps
- Expect(result.BitDepth).To(Equal(0)) // lossy codec
- })
-
- It("parses AAC-LC in m4a container (real ffprobe output)", func() {
- // Real: AAC LC file with profile and artwork
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` +
- `"profile":"LC","codec_type":"audio","sample_fmt":"fltp","sample_rate":"44100",` +
- `"channels":2,"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"279958"},` +
- `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.Codec).To(Equal("aac"))
- Expect(result.Profile).To(Equal("LC"))
- Expect(result.SampleRate).To(Equal(44100))
- Expect(result.Channels).To(Equal(2))
- Expect(result.BitRate).To(Equal(279)) // 279958 bps -> 279 kbps
- })
-
- It("parses HE-AACv2 in mp4 container with video stream (real ffprobe output)", func() {
- // Real: Fraunhofer HE-AACv2 sample (LFE-SBRstereo.mp4), video stream before audio
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"h264","codec_type":"video","profile":"Main"},` +
- `{"index":1,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` +
- `"profile":"HE-AACv2","codec_type":"audio","sample_fmt":"fltp",` +
- `"sample_rate":"48000","channels":2,"channel_layout":"stereo",` +
- `"bits_per_sample":0,"bit_rate":"55999"}]}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.Codec).To(Equal("aac"))
- Expect(result.Profile).To(Equal("HE-AACv2"))
- Expect(result.SampleRate).To(Equal(48000))
- Expect(result.Channels).To(Equal(2))
- Expect(result.BitRate).To(Equal(55)) // 55999 bps -> 55 kbps
- })
-
- It("parses FLAC using bits_per_raw_sample and format-level bit_rate (real ffprobe output)", func() {
- // Real: FLAC reports bit depth in bits_per_raw_sample, not bits_per_sample.
- // Stream-level bit_rate is absent; format-level bit_rate is used as fallback.
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` +
- `"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` +
- `"channel_layout":"stereo","bits_per_sample":0,"bits_per_raw_sample":"16"},` +
- `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}],` +
- `"format":{"bit_rate":"906900"}}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.Codec).To(Equal("flac"))
- Expect(result.SampleRate).To(Equal(44100))
- Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample
- Expect(result.BitRate).To(Equal(906)) // format-level: 906900 bps -> 906 kbps
- Expect(result.Profile).To(BeEmpty()) // no profile field in real output
- })
-
- It("parses Opus with format-level bit_rate fallback (real ffprobe output)", func() {
- // Real: Opus stream-level bit_rate is absent; format-level is used as fallback.
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"opus","codec_long_name":"Opus (Opus Interactive Audio Codec)",` +
- `"codec_type":"audio","sample_fmt":"fltp","sample_rate":"48000","channels":2,` +
- `"channel_layout":"stereo","bits_per_sample":0}],` +
- `"format":{"bit_rate":"128000"}}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.Codec).To(Equal("opus"))
- Expect(result.SampleRate).To(Equal(48000))
- Expect(result.Channels).To(Equal(2))
- Expect(result.BitRate).To(Equal(128)) // format-level: 128000 bps -> 128 kbps
- Expect(result.BitDepth).To(Equal(0))
- })
-
- It("parses WAV/PCM with bits_per_sample (real ffprobe output)", func() {
- // Real: WAV uses bits_per_sample directly
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"pcm_s16le","codec_long_name":"PCM signed 16-bit little-endian",` +
- `"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` +
- `"bits_per_sample":16,"bit_rate":"1411200"}]}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.Codec).To(Equal("pcm_s16le"))
- Expect(result.SampleRate).To(Equal(44100))
- Expect(result.Channels).To(Equal(2))
- Expect(result.BitDepth).To(Equal(16))
- Expect(result.BitRate).To(Equal(1411))
- })
-
- It("parses ALAC in m4a container (real ffprobe output)", func() {
- // Real: Beatles - You Can't Do That (2023 Mix), ALAC 16-bit
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"alac","codec_long_name":"ALAC (Apple Lossless Audio Codec)",` +
- `"codec_type":"audio","sample_fmt":"s16p","sample_rate":"44100","channels":2,` +
- `"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"1011003",` +
- `"bits_per_raw_sample":"16"},` +
- `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.Codec).To(Equal("alac"))
- Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample
- Expect(result.SampleRate).To(Equal(44100))
- Expect(result.Channels).To(Equal(2))
- Expect(result.BitRate).To(Equal(1011)) // 1011003 bps -> 1011 kbps
- })
-
- It("skips video-only streams", func() {
- data := []byte(`{"streams":[{"index":0,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
- _, err := parseProbeOutput(data)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("no audio stream"))
- })
-
- It("returns error for empty streams array", func() {
- data := []byte(`{"streams":[]}`)
- _, err := parseProbeOutput(data)
- Expect(err).To(HaveOccurred())
- })
-
- It("returns error for invalid JSON", func() {
- data := []byte(`not json`)
- _, err := parseProbeOutput(data)
- Expect(err).To(HaveOccurred())
- })
-
- It("parses HiRes multichannel FLAC with format-level bit_rate (real ffprobe output)", func() {
- // Real: Pink Floyd - 192kHz/24-bit/7.1 surround FLAC
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` +
- `"codec_type":"audio","sample_fmt":"s32","sample_rate":"192000","channels":8,` +
- `"channel_layout":"7.1","bits_per_sample":0,"bits_per_raw_sample":"24"},` +
- `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Progressive"}],` +
- `"format":{"bit_rate":"18432000"}}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.Codec).To(Equal("flac"))
- Expect(result.SampleRate).To(Equal(192000))
- Expect(result.BitDepth).To(Equal(24))
- Expect(result.Channels).To(Equal(8))
- Expect(result.BitRate).To(Equal(18432)) // format-level: 18432000 bps -> 18432 kbps
- })
-
- It("parses DSD/DSF file (real ffprobe output)", func() {
- // Real: Yes - Owner of a Lonely Heart, DSD64 DSF
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"dsd_lsbf_planar",` +
- `"codec_long_name":"DSD (Direct Stream Digital), least significant bit first, planar",` +
- `"codec_type":"audio","sample_fmt":"fltp","sample_rate":"352800","channels":2,` +
- `"channel_layout":"stereo","bits_per_sample":8,"bit_rate":"5644800"},` +
- `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.Codec).To(Equal("dsd_lsbf_planar"))
- Expect(result.BitDepth).To(Equal(8)) // DSD reports 8 bits_per_sample
- Expect(result.SampleRate).To(Equal(352800)) // DSD64 sample rate
- Expect(result.Channels).To(Equal(2))
- Expect(result.BitRate).To(Equal(5644)) // 5644800 bps -> 5644 kbps
- })
-
- It("prefers stream-level bit_rate over format-level when both are present", func() {
- // ALAC/DSD: stream has bit_rate, format also has bit_rate — stream wins
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"alac","codec_type":"audio","sample_fmt":"s16p",` +
- `"sample_rate":"44100","channels":2,"bits_per_sample":0,` +
- `"bit_rate":"1011003","bits_per_raw_sample":"16"}],` +
- `"format":{"bit_rate":"1050000"}}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.BitRate).To(Equal(1011)) // stream-level: 1011003 bps -> 1011 kbps (not format's 1050)
- })
-
- It("returns BitRate 0 when neither stream nor format has bit_rate", func() {
- data := []byte(`{"streams":[` +
- `{"index":0,"codec_name":"flac","codec_type":"audio","sample_fmt":"s16",` +
- `"sample_rate":"44100","channels":2,"bits_per_sample":0,"bits_per_raw_sample":"16"}],` +
- `"format":{}}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.BitRate).To(Equal(0))
- })
-
- It("clears 'unknown' profile to empty string", func() {
- data := []byte(`{"streams":[{"index":0,"codec_name":"flac",` +
- `"codec_type":"audio","profile":"unknown","sample_rate":"44100",` +
- `"channels":2,"bits_per_sample":0}]}`)
- result, err := parseProbeOutput(data)
- Expect(err).ToNot(HaveOccurred())
- Expect(result.Profile).To(BeEmpty())
- })
- })
-
- 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
-
- BeforeEach(func() {
- ffOnce = sync.Once{}
- ff = New()
- // Skip if FFmpeg is not available
- if !ff.IsAvailable() {
- Skip("FFmpeg not available on this system")
- }
- })
-
- 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()
-
- // Use a command that generates audio indefinitely
- // -f lavfi uses FFmpeg's built-in audio source
- // -t 0 means no time limit (runs forever)
- command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
-
- // The input file is not used here, but we need to provide a valid path to the Transcode function
- stream, err := ff.Transcode(ctx, TranscodeOptions{
- Command: command,
- Format: "mp3",
- FilePath: "tests/fixtures/test.mp3",
- BitRate: 128,
- })
- Expect(err).ToNot(HaveOccurred())
- defer stream.Close()
-
- // Read some data first to ensure FFmpeg is running
- buf := make([]byte, 1024)
- _, err = stream.Read(buf)
- Expect(err).ToNot(HaveOccurred())
-
- // Cancel the context
- cancel()
-
- // Subsequent reads should eventually fail due to cancelled context.
- // There may be buffered data in the pipe, so we drain until an error occurs.
- Eventually(func() error {
- _, err = stream.Read(buf)
- return err
- }).WithTimeout(5 * time.Second).WithPolling(10 * time.Millisecond).Should(HaveOccurred())
- })
-
- It("should handle immediate context cancellation", func() {
- ctx, cancel := context.WithCancel(GinkgoT().Context())
- cancel() // Cancel immediately
-
- // This should fail immediately
- _, err := ff.Transcode(ctx, TranscodeOptions{
- Command: "ffmpeg -i %s -f mp3 -",
- Format: "mp3",
- FilePath: "tests/fixtures/test.mp3",
- BitRate: 128,
- })
- 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() {
- BeforeEach(func() {
- if runtime.GOOS == "windows" {
- Skip("stderr capture tests use /bin/sh, skipping on Windows")
- }
- })
-
- It("should include stderr in error when process fails", func() {
- ff := &ffmpeg{}
- ctx := GinkgoT().Context()
-
- // Directly call start() with a bash command that writes to stderr and fails
- args := []string{"/bin/sh", "-c", "echo 'codec not found: libopus' >&2; exit 1"}
- stream, err := ff.start(ctx, args)
- Expect(err).ToNot(HaveOccurred())
- defer stream.Close()
-
- buf := make([]byte, 1024)
- _, err = stream.Read(buf)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("codec not found: libopus"))
- })
-
- It("should not include stderr in error when process succeeds", func() {
- ff := &ffmpeg{}
- ctx := GinkgoT().Context()
-
- // Command that writes to stderr but exits successfully
- args := []string{"/bin/sh", "-c", "echo 'warning: something' >&2; printf 'output'"}
- stream, err := ff.start(ctx, args)
- Expect(err).ToNot(HaveOccurred())
- defer stream.Close()
-
- buf := make([]byte, 1024)
- n, err := stream.Read(buf)
- Expect(err).ToNot(HaveOccurred())
- Expect(string(buf[:n])).To(Equal("output"))
- })
- })
-
- Context("with mock process behavior", func() {
- var longRunningCmd string
- BeforeEach(func() {
- // Use a long-running command for testing cancellation
- switch runtime.GOOS {
- case "windows":
- // Use PowerShell's Start-Sleep
- ffmpegPath = "powershell"
- longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
- default:
- // Use sleep on Unix-like systems
- ffmpegPath = "sleep"
- longRunningCmd = "sleep 10"
- }
- })
-
- It("should terminate the underlying process when context is cancelled", func() {
- ff := New()
- ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
- defer cancel()
-
- // Start a process that will run for a while
- stream, err := ff.Transcode(ctx, TranscodeOptions{
- Command: longRunningCmd,
- FilePath: "tests/fixtures/test.mp3",
- })
- Expect(err).ToNot(HaveOccurred())
- defer stream.Close()
-
- // Give the process time to start
- time.Sleep(50 * time.Millisecond)
-
- // Cancel the context
- cancel()
-
- // Try to read from the stream, which should fail
- buf := make([]byte, 100)
- _, err = stream.Read(buf)
- Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
-
- // Verify the stream is closed by attempting another read
- _, err = stream.Read(buf)
- Expect(err).To(HaveOccurred())
- })
- })
- })
-
- Describe("parseEncodersOutput", func() {
- const sample = `Encoders:
- V..... = Video
- ------
- V....D apng APNG (Animated Portable Network Graphics) image
- V....D libwebp_anim libwebp WebP image (codec webp)
- V....D libwebp libwebp WebP image (codec webp)
- A....D aac AAC (Advanced Audio Coding)
-`
- It("returns true when the encoder is present", func() {
- Expect(parseEncodersOutput([]byte(sample), "libwebp_anim")).To(BeTrue())
- Expect(parseEncodersOutput([]byte(sample), "libwebp")).To(BeTrue())
- Expect(parseEncodersOutput([]byte(sample), "aac")).To(BeTrue())
- })
- It("returns false when the encoder is absent", func() {
- Expect(parseEncodersOutput([]byte(sample), "libwebp_missing")).To(BeFalse())
- Expect(parseEncodersOutput([]byte(sample), "")).To(BeFalse())
- })
- It("does not match partial names", func() {
- // libwebp is a prefix of libwebp_anim; the parser must treat names as whole-word.
- stripped := `Encoders:
- V....D libwebp libwebp WebP image (codec webp)
-`
- Expect(parseEncodersOutput([]byte(stripped), "libwebp_anim")).To(BeFalse())
- })
- It("handles empty output", func() {
- Expect(parseEncodersOutput(nil, "libwebp_anim")).To(BeFalse())
- Expect(parseEncodersOutput([]byte(""), "libwebp_anim")).To(BeFalse())
- })
- })
-
- Describe("ConvertAnimatedImage", func() {
- // Point ffmpegCmd at a stand-in binary that produces empty `-encoders`
- // output so hasAnimatedWebPEncoder returns false. /usr/bin/true is
- // portable across POSIX systems.
- It("returns ErrAnimatedWebPUnsupported when the binary lacks libwebp_anim", func() {
- truePath, err := exec.LookPath("true")
- if err != nil {
- Skip("true(1) not available")
- }
- origPath, origErr := ffmpegPath, ffmpegErr
- ffmpegPath = truePath
- ffmpegErr = nil
- defer func() {
- ffmpegPath, ffmpegErr = origPath, origErr
- }()
-
- ff := &ffmpeg{}
- _, err = ff.ConvertAnimatedImage(GinkgoT().Context(), strings.NewReader("x"), 100, 75)
- Expect(err).To(MatchError(ErrAnimatedWebPUnsupported))
+ args := createProbeCommand(probeCmd, []string{"one.mp3"})
+ Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
})
})
})
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/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..7abd35c8f 100644
--- a/core/library.go
+++ b/core/library.go
@@ -7,7 +7,6 @@ import (
"io/fs"
"os"
"path/filepath"
- "slices"
"strconv"
"strings"
"time"
@@ -17,12 +16,16 @@ 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"
)
+// Scanner interface for triggering scans
+type Scanner interface {
+ ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
+}
+
// Watcher interface for managing file system watchers
type Watcher interface {
Watch(ctx context.Context, lib *model.Library) error
@@ -35,28 +38,23 @@ 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 Scanner
+ watcher Watcher
+ broker events.Broker
}
// NewLibrary creates a new Library service
-func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library {
+func NewLibrary(ds model.DataStore, scanner Scanner, watcher Watcher, broker events.Broker) 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,
}
}
@@ -64,16 +62,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 +94,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 +119,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,109 +136,111 @@ 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,
+ }
+ return wrapper
}
-var _ rest.Persistable[model.Library] = (*libraryRepositoryWrapper)(nil)
-
type libraryRepositoryWrapper struct {
+ rest.Repository
model.LibraryRepository
- ds model.DataStore
- scanner model.Scanner
- watcher Watcher
- broker events.Broker
- pluginManager PluginUnloader
+ ctx context.Context
+ ds model.DataStore
+ scanner Scanner
+ watcher Watcher
+ broker events.Broker
}
-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 interface{}) (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 interface{}, cols ...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,42 +249,34 @@ 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)
-
return nil
}
@@ -308,10 +300,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 +321,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 +333,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 +351,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 +359,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 +386,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 +400,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..bfbb4300a 100644
--- a/core/library_test.go
+++ b/core/library_test.go
@@ -9,7 +9,7 @@ import (
"sync"
"github.com/deluan/rest"
- _ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor
+ _ "github.com/navidrome/navidrome/adapters/taglib" // Register taglib extractor
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
@@ -29,10 +29,9 @@ var _ = Describe("Library Service", func() {
var userRepo *tests.MockedUserRepo
var ctx context.Context
var tempDir string
- var scanner *tests.MockScanner
+ var scanner *mockScanner
var watcherManager *mockWatcherManager
var broker *mockEventBroker
- var pluginManager *mockPluginUnloader
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@@ -44,16 +43,14 @@ var _ = Describe("Library Service", func() {
ds.MockedUser = userRepo
// Create a mock scanner that tracks calls
- scanner = tests.NewMockScanner()
+ scanner = &mockScanner{}
// Create a mock watcher manager
watcherManager = &mockWatcherManager{
libraryStates: make(map[int]model.Library),
}
// Create a mock event broker
broker = &mockEventBroker{}
- // Create a mock plugin unloader
- pluginManager = &mockPluginUnloader{}
- service = core.NewLibrary(ds, scanner, watcherManager, broker, pluginManager)
+ service = core.NewLibrary(ds, scanner, watcherManager, broker)
ctx = context.Background()
// Create a temporary directory for testing valid paths
@@ -66,18 +63,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 +84,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 +93,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 +102,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 +137,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 +154,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 +178,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 +193,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 +202,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 +212,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 +227,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 +241,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 +272,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 +300,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 +310,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 +327,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 +342,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 +359,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 +381,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 +398,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 +407,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 +422,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 +434,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 +455,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,27 +601,26 @@ 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
Eventually(func() int {
- return scanner.GetScanAllCallCount()
+ return scanner.len()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
- calls := scanner.GetScanAllCalls()
- Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
+ Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("triggers scan when updating library path", func() {
@@ -680,17 +636,16 @@ 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
Eventually(func() int {
- return scanner.GetScanAllCallCount()
+ return scanner.len()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
- calls := scanner.GetScanAllCalls()
- Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
+ Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("does not trigger scan when updating library without path change", func() {
@@ -701,67 +656,25 @@ 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
Consistently(func() int {
- return scanner.GetScanAllCallCount()
+ return scanner.len()
}, "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
Consistently(func() int {
- return scanner.GetScanAllCallCount()
+ return scanner.len()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -773,12 +686,12 @@ 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
Consistently(func() int {
- return scanner.GetScanAllCallCount()
+ return scanner.len()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -789,27 +702,26 @@ 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
Eventually(func() int {
- return scanner.GetScanAllCallCount()
+ return scanner.len()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
- calls := scanner.GetScanAllCalls()
- Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
+ Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
})
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
Consistently(func() int {
- return scanner.GetScanAllCallCount()
+ return scanner.len()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -817,7 +729,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 +758,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 +778,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 +793,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 +811,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 +823,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 +835,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 +848,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,49 +860,37 @@ 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))
})
})
-
- Describe("Plugin Manager Integration", func() {
- var repo rest.Persistable[model.Library]
-
- BeforeEach(func() {
- // Reset the call count for each test
- pluginManager.unloadCalls = 0
- r := service.Repository()
- repo = r.(rest.Persistable[model.Library])
- })
-
- It("calls UnloadDisabledPlugins after successful library deletion", func() {
- libraryRepo.SetData(model.Libraries{
- {ID: 2, Name: "Library to Delete", Path: tempDir},
- })
-
- err := repo.Delete(ctx, "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")
- Expect(err).To(HaveOccurred())
- Expect(pluginManager.unloadCalls).To(Equal(0))
- })
- })
})
-// mockPluginUnloader is a simple mock for testing UnloadDisabledPlugins calls
-type mockPluginUnloader struct {
- unloadCalls int
+// mockScanner provides a simple mock implementation of core.Scanner for testing
+type mockScanner struct {
+ ScanCalls []ScanCall
+ mu sync.RWMutex
}
-func (m *mockPluginUnloader) UnloadDisabledPlugins(ctx context.Context) {
- m.unloadCalls++
+type ScanCall struct {
+ FullScan bool
+}
+
+func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.ScanCalls = append(m.ScanCalls, ScanCall{
+ FullScan: fullScan,
+ })
+ return []string{}, nil
+}
+
+func (m *mockScanner) len() int {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return len(m.ScanCalls)
}
// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go
index d3e8d72b8..858a3ffd8 100644
--- a/core/lyrics/lyrics.go
+++ b/core/lyrics/lyrics.go
@@ -4,122 +4,34 @@ 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
+func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
+ var lyricsList model.LyricList
+ var err error
-// Provider fetches lyrics for a single media file. It is the contract
-// implemented by individual lyrics sources, such as plugins.
-type Provider interface {
- GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
-}
-
-// Lyrics resolves lyrics for media files, honoring the configured source
-// priority.
-type Lyrics interface {
- Provider
- GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error)
-}
-
-// PluginLoader discovers and loads lyrics provider plugins.
-type PluginLoader interface {
- LoadLyricsProvider(name string) (Provider, bool)
-}
-
-type lyricsService struct {
- ds model.DataStore
- pluginLoader PluginLoader
-}
-
-// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
-// system is available.
-func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics {
- return &lyricsService{ds: ds, pluginLoader: pluginLoader}
-}
-
-// GetLyrics returns lyrics for the given media file, trying sources in the
-// order specified by conf.Server.LyricsPriority.
-func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
- return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf})
-}
-
-// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup,
-// scanning a bounded window of duplicate matches so source priority still wins
-// across them.
-func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) {
- opts := songsByArtistTitleWithLyricsFirst(artist, title)
- opts.Max = maxLegacyLyricsCandidates
- mediaFiles, err := l.ds.MediaFile().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, ",") {
+ for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
pattern = strings.TrimSpace(pattern)
- if pattern == "" {
- continue
+ switch {
+ case pattern == "embedded":
+ lyricsList, err = fromEmbedded(ctx, mf)
+ case strings.HasPrefix(pattern, "."):
+ lyricsList, err = fromExternalFile(ctx, mf, pattern)
+ default:
+ log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
}
- for _, mf := range mediaFiles {
- if mf == nil {
- continue
- }
+ if err != nil {
+ log.Error(ctx, "error parsing 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..f4197ccf6 100644
--- a/core/lyrics/lyrics_test.go
+++ b/core/lyrics/lyrics_test.go
@@ -3,33 +3,25 @@ package lyrics_test
import (
"context"
"encoding/json"
- "fmt"
"os"
- "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/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 +30,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 +58,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 +67,18 @@ 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)
- list, err := svc.GetLyrics(ctx, &mf)
+ list, err := lyrics.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
@@ -269,7 +91,6 @@ var _ = Describe("Lyrics", func() {
var accessForbiddenFile string
BeforeEach(func() {
- tests.SkipOnWindows("uses Unix file permission bits")
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
@@ -286,8 +107,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)
- list, err := svc.GetLyrics(ctx, &mf)
+ list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
})
@@ -295,153 +115,10 @@ 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)
- list, err := svc.GetLyrics(ctx, &mf)
+ list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(BeEmpty())
})
})
})
-
- Context("plugin sources", func() {
- var mockLoader *mockPluginLoader
-
- BeforeEach(func() {
- mockLoader = &mockPluginLoader{}
- })
-
- It("should return lyrics from a plugin", func() {
- conf.Server.LyricsPriority = "test-lyrics-plugin"
- mockLoader.lyrics = unsyncedLyrics
- svc := lyrics.NewLyrics(nil, mockLoader)
- list, err := svc.GetLyrics(ctx, &mf)
- Expect(err).To(BeNil())
- Expect(list).To(Equal(unsyncedLyrics))
- })
-
- It("should try plugin after embedded returns nothing", func() {
- conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
- mf.Lyrics = "" // No embedded lyrics
- mockLoader.lyrics = unsyncedLyrics
- svc := lyrics.NewLyrics(nil, mockLoader)
- list, err := svc.GetLyrics(ctx, &mf)
- Expect(err).To(BeNil())
- Expect(list).To(Equal(unsyncedLyrics))
- })
-
- It("should skip plugin if embedded has lyrics", func() {
- conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
- mockLoader.lyrics = unsyncedLyrics
- svc := lyrics.NewLyrics(nil, mockLoader)
- list, err := svc.GetLyrics(ctx, &mf)
- Expect(err).To(BeNil())
- Expect(list).To(Equal(embeddedLyrics)) // embedded wins
- })
-
- It("should skip unknown plugin names gracefully", func() {
- conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
- mockLoader.notFound = true
- svc := lyrics.NewLyrics(nil, mockLoader)
- list, err := svc.GetLyrics(ctx, &mf)
- Expect(err).To(BeNil())
- Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
- })
-
- It("should preserve plugin name case from config", func() {
- conf.Server.LyricsPriority = "MyLyricsPlugin"
- mockLoader.pluginName = "MyLyricsPlugin"
- mockLoader.lyrics = unsyncedLyrics
- svc := lyrics.NewLyrics(nil, mockLoader)
- list, err := svc.GetLyrics(ctx, &mf)
- Expect(err).To(BeNil())
- Expect(list).To(Equal(unsyncedLyrics))
- })
-
- 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)
- 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 {
- lyrics model.LyricList
- err error
- notFound bool
- pluginName string // expected plugin name (exact match, like real manager)
-}
-
-func (m *mockPluginLoader) PluginNames(_ string) []string {
- if m.notFound {
- return nil
- }
- return []string{"test-lyrics-plugin"}
-}
-
-func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
- if m.notFound {
- return nil, false
- }
- // If pluginName is set, require exact match (like the real plugin manager)
- if m.pluginName != "" && name != m.pluginName {
- return nil, false
- }
- return &mockLyricsProvider{lyrics: m.lyrics, err: m.err}, true
-}
-
-type mockLyricsProvider struct {
- lyrics model.LyricList
- err error
-}
-
-func (m *mockLyricsProvider) GetLyrics(_ context.Context, _ *model.MediaFile) (model.LyricList, error) {
- return m.lyrics, m.err
-}
diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go
index 23c20122d..857dc2eef 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,68 +23,29 @@ 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
-}
-
-// fromPlugin attempts to load lyrics from a plugin with the given name.
-func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) {
- if l.pluginLoader == nil {
- log.Debug(ctx, "Invalid lyric source", "source", pluginName)
- return nil, nil
- }
-
- provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName)
- if !ok {
- log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName)
- return nil, nil
- }
-
- lyricsList, err := provider.GetLyrics(ctx, mf)
- if err != nil {
- return nil, err
- }
-
- if len(lyricsList) > 0 {
- log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
- }
- return lyricsList, nil
+ log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
+
+ return model.LyricList{*lyrics}, nil
}
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..1905851f3 100644
--- a/core/maintenance.go
+++ b/core/maintenance.go
@@ -2,26 +2,13 @@ package core
import (
"context"
- "errors"
"fmt"
- "slices"
- "sync"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
- "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 {
@@ -29,14 +16,12 @@ type Maintenance interface {
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
+ // RefreshAlbums recalculates album attributes from media files
+ RefreshAlbums(ctx context.Context, albumIDs []string) error
}
type maintenanceService struct {
ds model.DataStore
- wg sync.WaitGroup
}
func NewMaintenance(ds model.DataStore) Maintenance {
@@ -53,87 +38,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 +50,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,16 +66,15 @@ 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
}
-// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
+// RefreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
// It uses batch queries to minimize database round-trips for efficiency.
-func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error {
+func (s *maintenanceService) RefreshAlbums(ctx context.Context, albumIDs []string) error {
if len(albumIDs) == 0 {
return nil
}
@@ -180,7 +83,13 @@ func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []strin
// Process in chunks to avoid query size limits
const chunkSize = 100
- for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) {
+ for i := 0; i < len(albumIDs); i += chunkSize {
+ end := i + chunkSize
+ if end > len(albumIDs) {
+ end = len(albumIDs)
+ }
+ chunk := albumIDs[i:end]
+
if err := s.refreshAlbumChunk(ctx, chunk); err != nil {
return fmt.Errorf("refreshing album chunk: %w", err)
}
@@ -192,11 +101,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 +119,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 +152,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
@@ -261,11 +170,11 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri
if len(ids) > 0 {
filters = squirrel.And{
squirrel.Eq{"missing": true},
- squirrel.Eq{"media_file.id": ids},
+ squirrel.Eq{"id": ids},
}
}
- mfs, err := s.ds.MediaFile().GetAll(ctx, model.QueryOptions{
+ mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: filters,
})
if err != nil {
@@ -291,9 +200,9 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri
// refreshStatsAsync refreshes artist and album statistics in background goroutines
func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
// Refresh artist stats in background
- s.wg.Go(func() {
+ 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")
@@ -301,19 +210,11 @@ func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbu
// Refresh album stats in background if we have affected albums
if len(affectedAlbumIDs) > 0 {
- if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil {
+ if err := s.RefreshAlbums(bgCtx, affectedAlbumIDs); err != nil {
log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err)
} else {
log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
}
}
- })
-}
-
-// Wait waits for all background goroutines to complete.
-// WARNING: This method is ONLY for testing. Never call this in production code.
-// Calling Wait() in production will block until ALL background operations complete
-// and may cause race conditions with new operations starting.
-func (s *maintenanceService) wait() {
- s.wg.Wait()
+ }()
}
diff --git a/core/maintenance_test.go b/core/maintenance_test.go
index 4ffc098d9..303dfc9b7 100644
--- a/core/maintenance_test.go
+++ b/core/maintenance_test.go
@@ -3,15 +3,12 @@ package core
import (
"context"
"errors"
- "sync"
- "time"
"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"
- "github.com/sirupsen/logrus"
)
var _ = Describe("Maintenance", func() {
@@ -24,14 +21,13 @@ var _ = Describe("Maintenance", func() {
ctx = context.Background()
ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true})
- ds = createTestDataStore()
- mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo)
+ ds, mfRepo = createTestDataStore()
service = NewMaintenance(ds)
})
Describe("DeleteMissingFiles", func() {
Context("with specific IDs", func() {
- It("deletes specific missing files and runs GC", func() {
+ It("deletes specific missing files", func() {
// Setup: mock missing files with album IDs
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
@@ -43,34 +39,6 @@ var _ = Describe("Maintenance", func() {
Expect(err).ToNot(HaveOccurred())
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"}))
- Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
- })
-
- It("triggers artist stats refresh and album refresh after deletion", func() {
- artistRepo := ds.MockedArtist.(*extendedArtistRepo)
- // Setup: mock missing files with albums
- albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
- albumRepo.SetData(model.Albums{
- {ID: "album1", Name: "Test Album", SongCount: 5},
- })
- mfRepo.SetData(model.MediaFiles{
- {ID: "mf1", AlbumID: "album1", Missing: true},
- {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
- {ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200},
- })
-
- err := service.DeleteMissingFiles(ctx, []string{"mf1"})
-
- Expect(err).ToNot(HaveOccurred())
-
- // Wait for background goroutines to complete
- service.(*maintenanceService).wait()
-
- // RefreshStats should be called
- Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
-
- // Album should be updated with new calculated values
- Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data")
})
It("returns error if deletion fails", func() {
@@ -97,10 +65,11 @@ var _ = Describe("Maintenance", func() {
{ID: "mf1", AlbumID: "album1", Missing: true},
})
- // Set GC to return error
- ds.GCError = errors.New("gc failed")
+ // Create a wrapper that returns error on GC
+ dsWithGCError := &mockDataStoreWithGCError{MockDataStore: ds}
+ serviceWithError := NewMaintenance(dsWithGCError)
- err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+ err := serviceWithError.DeleteMissingFiles(ctx, []string{"mf1"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("gc failed"))
@@ -134,7 +103,7 @@ var _ = Describe("Maintenance", func() {
})
Describe("DeleteAllMissingFiles", func() {
- It("deletes all missing files and runs GC", func() {
+ It("deletes all missing files", func() {
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
{ID: "mf2", AlbumID: "album2", Missing: true},
@@ -144,7 +113,6 @@ var _ = Describe("Maintenance", func() {
err := service.DeleteAllMissingFiles(ctx)
Expect(err).ToNot(HaveOccurred())
- Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
})
It("returns error if deletion fails", func() {
@@ -163,272 +131,13 @@ var _ = Describe("Maintenance", func() {
Expect(err).ToNot(HaveOccurred())
})
})
-
- Describe("Album refresh logic", func() {
- var albumRepo *extendedAlbumRepo
-
- BeforeEach(func() {
- albumRepo = ds.MockedAlbum.(*extendedAlbumRepo)
- })
-
- Context("when album has no tracks after deletion", func() {
- It("skips the album without updating it", func() {
- // Setup album with no remaining tracks
- albumRepo.SetData(model.Albums{
- {ID: "album1", Name: "Empty Album", SongCount: 1},
- })
- mfRepo.SetData(model.MediaFiles{
- {ID: "mf1", AlbumID: "album1", Missing: true},
- })
-
- err := service.DeleteMissingFiles(ctx, []string{"mf1"})
-
- Expect(err).ToNot(HaveOccurred())
-
- // Wait for background goroutines to complete
- service.(*maintenanceService).wait()
-
- // Album should NOT be updated because it has no tracks left
- Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated")
- })
- })
-
- Context("when Put fails for one album", func() {
- It("continues processing other albums", func() {
- albumRepo.SetData(model.Albums{
- {ID: "album1", Name: "Album 1"},
- {ID: "album2", Name: "Album 2"},
- })
- mfRepo.SetData(model.MediaFiles{
- {ID: "mf1", AlbumID: "album1", Missing: true},
- {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
- {ID: "mf3", AlbumID: "album2", Missing: true},
- {ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200},
- })
-
- // Make Put fail on first call but succeed on subsequent calls
- albumRepo.putError = errors.New("put failed")
- albumRepo.failOnce = true
-
- err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"})
-
- // Should not fail even if one album's Put fails
- Expect(err).ToNot(HaveOccurred())
-
- // Wait for background goroutines to complete
- service.(*maintenanceService).wait()
-
- // Put should have been called multiple times
- Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted")
- })
- })
-
- Context("when media file loading fails", func() {
- It("logs warning but continues when tracking affected albums fails", func() {
- // Set up log capturing
- hook, cleanup := tests.LogHook()
- defer cleanup()
-
- albumRepo.SetData(model.Albums{
- {ID: "album1", Name: "Album 1"},
- })
- mfRepo.SetData(model.MediaFiles{
- {ID: "mf1", AlbumID: "album1", Missing: true},
- })
- // Make GetAll fail when loading media files
- mfRepo.SetError(true)
-
- err := service.DeleteMissingFiles(ctx, []string{"mf1"})
-
- // Deletion should succeed despite the tracking error
- Expect(err).ToNot(HaveOccurred())
- Expect(mfRepo.deleteMissingCalled).To(BeTrue())
-
- // Verify the warning was logged
- Expect(hook.LastEntry()).ToNot(BeNil())
- Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
- Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh"))
- })
- })
- })
-
- 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
-func createTestDataStore() *tests.MockDataStore {
+func createTestDataStore() (*tests.MockDataStore, *extendedMediaFileRepo) {
ds := &tests.MockDataStore{}
-
- // Create extended album repo with Put tracking
- albumRepo := &extendedAlbumRepo{
- MockAlbumRepo: tests.CreateMockAlbumRepo(),
- }
- ds.MockedAlbum = albumRepo
-
- // Create extended artist repo with RefreshStats tracking
- artistRepo := &extendedArtistRepo{
- MockArtistRepo: tests.CreateMockArtistRepo(),
- }
- ds.MockedArtist = artistRepo
+ ds.MockedAlbum = tests.CreateMockAlbumRepo()
+ ds.MockedArtist = tests.CreateMockArtistRepo()
// Create extended media file repo with DeleteMissing support
mfRepo := &extendedMediaFileRepo{
@@ -436,7 +145,7 @@ func createTestDataStore() *tests.MockDataStore {
}
ds.MockedMediaFile = mfRepo
- return ds
+ return ds, mfRepo
}
// Extension of MockMediaFileRepo to add DeleteMissing method
@@ -447,7 +156,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 {
@@ -460,67 +169,11 @@ func (m *extendedMediaFileRepo) DeleteMissing(ctx context.Context, ids []string)
return nil
}
-// Extension of MockAlbumRepo to track Put calls
-type extendedAlbumRepo struct {
- *tests.MockAlbumRepo
- mu sync.RWMutex
- putCallCount int
- lastPutData *model.Album
- putError error
- failOnce bool
+// Wrapper to override GC method to return error
+type mockDataStoreWithGCError struct {
+ *tests.MockDataStore
}
-func (m *extendedAlbumRepo) Put(ctx context.Context, album *model.Album) error {
- m.mu.Lock()
- m.putCallCount++
- m.lastPutData = album
-
- // Handle failOnce behavior
- var err error
- if m.putError != nil {
- if m.failOnce {
- err = m.putError
- m.putError = nil // Clear error after first failure
- m.mu.Unlock()
- return err
- }
- err = m.putError
- m.mu.Unlock()
- return err
- }
- m.mu.Unlock()
-
- return m.MockAlbumRepo.Put(ctx, album)
-}
-
-func (m *extendedAlbumRepo) GetPutCallCount() int {
- m.mu.RLock()
- defer m.mu.RUnlock()
- return m.putCallCount
-}
-
-// Extension of MockArtistRepo to track RefreshStats calls
-type extendedArtistRepo struct {
- *tests.MockArtistRepo
- mu sync.RWMutex
- refreshStatsCalled bool
- refreshStatsError error
-}
-
-func (m *extendedArtistRepo) RefreshStats(ctx context.Context, allArtists bool) (int64, error) {
- m.mu.Lock()
- m.refreshStatsCalled = true
- err := m.refreshStatsError
- m.mu.Unlock()
-
- if err != nil {
- return 0, err
- }
- return m.MockArtistRepo.RefreshStats(ctx, allArtists)
-}
-
-func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
- m.mu.RLock()
- defer m.mu.RUnlock()
- return m.refreshStatsCalled
+func (ds *mockDataStoreWithGCError) GC(ctx context.Context) error {
+ return errors.New("gc failed")
}
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
deleted file mode 100644
index 3eb4db152..000000000
--- a/core/matcher/matcher.go
+++ /dev/null
@@ -1,642 +0,0 @@
-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"
-)
-
-// Matcher matches agent song results to local library tracks.
-type Matcher struct {
- ds model.DataStore
-}
-
-// New creates a new Matcher with the given DataStore.
-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.
-//
-// Each library track appears at most once, unless the same input song is
-// repeated: identical input songs intentionally yield repeated output tracks,
-// while distinct songs that resolve to the same track are deduplicated. Songs
-// that cannot be matched are skipped.
-func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
- if len(songs) == 0 {
- return nil, nil
- }
- matches, err := m.resolveMatches(ctx, songs)
- if err != nil {
- return nil, err
- }
- return orderAndDedup(songs, matches, 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.
-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) {
- 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)
- }
- 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 {
- var ids []string
- for _, s := range songs {
- if s.ID != "" {
- ids = append(ids, s.ID)
- }
- }
- if len(ids) == 0 {
- return nil
- }
- res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
- Filters: squirrel.And{
- squirrel.Eq{"media_file.id": ids},
- squirrel.Eq{"missing": false},
- },
- })
- if err != nil {
- return 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
- }
- }
- return 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 {
- var mbids []string
- for i, s := range songs {
- if _, done := result[i]; done {
- continue
- }
- if s.MBID != "" {
- mbids = append(mbids, s.MBID)
- }
- }
- if len(mbids) == 0 {
- return nil
- }
- res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{
- Filters: squirrel.And{
- squirrel.Eq{"mbz_recording_id": mbids},
- squirrel.Eq{"missing": false},
- },
- })
- if err != nil {
- return 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
- }
- }
- }
- 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
-}
-
-// matchByISRC fills result with ISRC tag matches, skipping songs already
-// matched by a higher-priority loader.
-func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
- var isrcs []string
- for i, s := range songs {
- if _, done := result[i]; done {
- continue
- }
- if s.ISRC != "" {
- isrcs = append(isrcs, s.ISRC)
- }
- }
- if len(isrcs) == 0 {
- return nil
- }
- res, err := m.ds.MediaFile().GetAllByTags(ctx, model.TagISRC, isrcs, model.QueryOptions{
- Filters: squirrel.Eq{"missing": false},
- Sort: "starred desc, rating desc, year asc, compilation asc",
- })
- if err != nil {
- return 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
- }
- }
- }
- for i, s := range songs {
- if _, done := result[i]; done {
- continue
- }
- if s.ISRC == "" {
- continue
- }
- if mf, ok := byISRC[s.ISRC]; ok {
- result[i] = mf
- }
- }
- return nil
-}
-
-// queryArtist is one of a song's artists. A non-empty id is matched directly, skipping name/MBID
-// resolution; name is pre-sanitized (article-stripped).
-type queryArtist struct {
- id string
- name string
- mbid string
-}
-
-// songQuery represents a normalized query for matching a song to library tracks.
-type songQuery struct {
- title string
- artists []queryArtist
- album string
- albumMBID string
- durationMs uint32
-}
-
-// matchScore combines title/album similarity with metadata specificity for ranking matches.
-type matchScore struct {
- titleSimilarity float64
- durationProximity float64
- preferredMatch bool
- specificityLevel int
- artistOverlap int
- albumSimilarity float64
-}
-
-// betterThan returns true if this score beats another.
-// Identity signals (specificity, overlap) outrank the taste signal (preferred).
-func (s matchScore) betterThan(other matchScore) bool {
- if s.titleSimilarity != other.titleSimilarity {
- return s.titleSimilarity > other.titleSimilarity
- }
- 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
- }
- 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.
-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)
-}
-
-func newSanitizedTrack(mf *model.MediaFile, artistIDs, artistMBIDs map[string]struct{}) sanitizedTrack {
- return sanitizedTrack{
- mf: mf,
- title: str.SanitizeFieldForSorting(mf.Title),
- artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
- album: str.SanitizeFieldForSorting(mf.Album),
- artistIDs: artistIDs,
- artistMBIDs: artistMBIDs,
- }
-}
-
-// computeSpecificityLevel determines how well query metadata matches a track (0-5), taking the best
-// level achievable across any of the query's artists. Fields must be pre-sanitized.
-//
-// A query artist counts as an identity match when the track credits its resolved Navidrome ID (the
-// strongest signal, our own primary key) or its MBID; that identity then unlocks the album tiers.
-// Name matching is the lowest fallback for an artist with no identity match (e.g. a cover credited
-// to a different artist by the same name).
-func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int {
- best := 0
- albumOK := q.album != "" && similarityRatio(t.album, q.album) >= albumThreshold
- for _, a := range q.artists {
- _, idMember := t.artistIDs[a.id]
- _, mbidMember := t.artistMBIDs[a.mbid]
- identity := (a.id != "" && idMember) || (a.mbid != "" && mbidMember)
- level := 0
- switch {
- case identity && q.albumMBID != "" && t.mf.MbzAlbumID == q.albumMBID:
- level = 5
- case identity && q.album != "" && albumOK:
- level = 4
- case a.name != "" && q.album != "" && t.artist == a.name && albumOK:
- level = 3
- case identity:
- level = 2
- case a.name != "" && t.artist == a.name:
- level = 1
- }
- if level > best {
- best = level
- }
- }
- return best
-}
-
-// indexedQuery pairs a normalized songQuery with the index of the input song
-// it came from, so title matches can be written back to result by index.
-type indexedQuery struct {
- index int
- query songQuery
-}
-
-// matchByTitle fills result with fuzzy title+artist matches, skipping songs
-// already matched by a higher-priority loader.
-func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
- queries := groupQueries(songs, result)
- if len(queries) == 0 {
- return nil
- }
-
- resolved, err := m.resolveArtists(ctx, queries)
- if err != nil || len(resolved.allIDs) == 0 {
- return err
- }
-
- tracks, err := m.fetchTracksCreditedTo(ctx, resolved.allIDs)
- if err != nil {
- return err
- }
-
- tracksByQuery := resolved.bucketTracks(tracks)
- threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
- for _, iq := range queries {
- sanitized := tracksByQuery[iq.index]
- if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found {
- result[iq.index] = mf
- }
- }
- return nil
-}
-
-// groupQueries builds one normalized title query per still-unmatched song, carrying its full
-// artist set. An artist is usable if it carries a Navidrome ID or a non-empty sanitized name;
-// songs with no usable artist are skipped (the title phase needs at least one to scope the query).
-func groupQueries(songs []agents.Song, result map[int]model.MediaFile) []indexedQuery {
- var queries []indexedQuery
- for i, s := range songs {
- if _, done := result[i]; done {
- continue
- }
- 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)
- }
- }
-
- // 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})
- if err != nil {
- return resolvedArtists{}, err
- }
- for _, a := range artists {
- res.mbid[a.ID] = a.MbzArtistID
- allIDs[a.ID] = struct{}{}
- for _, idx := range nameToQueries[a.OrderArtistName] {
- addToSet(res.byQuery, idx, a.ID)
- }
- if a.MbzArtistID != "" {
- for _, idx := range mbidToQueries[a.MbzArtistID] {
- addToSet(res.byQuery, idx, a.ID)
- }
- }
- }
- }
-
- 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",
- })
-}
-
-// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration
-// is to the target. Returns 1.0 if durationMs is 0 (unknown).
-func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 {
- if durationMs == 0 {
- return 1.0
- }
- durationSec := float64(durationMs) / 1000.0
- diff := math.Abs(durationSec - float64(mediaFileDurationSec))
- return 1.0 / (1.0 + diff)
-}
-
-// 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) {
- 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)
- if titleSim < threshold {
- continue
- }
- var albumSim float64
- if q.album != "" {
- albumSim = similarityRatio(q.album, c.album)
- }
- score := matchScore{
- titleSimilarity: titleSim,
- durationProximity: durationProximity(q.durationMs, c.mf.Duration),
- preferredMatch: preferStarred && isPreferredTrack(c.mf),
- albumSimilarity: albumSim,
- specificityLevel: computeSpecificityLevel(q, c.sanitizedTrack, threshold),
- artistOverlap: c.overlap,
- }
- if score.betterThan(bestScore) {
- bestScore = score
- bestMatch = *c.mf
- found = true
- }
- }
- return bestMatch, found
-}
-
-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))
-
- for i, s := range songs {
- if len(mfs) == count {
- break
- }
- mf, found := matches[i]
- if !found {
- continue
- }
- if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
- if !s.Equals(songs[prevIdx]) {
- continue
- }
- } else {
- addedBy[mf.ID] = i
- }
- mfs = append(mfs, mf)
- }
- return mfs
-}
-
-// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
-func similarityRatio(a, b string) float64 {
- if a == b {
- return 1.0
- }
- if len(a) == 0 || len(b) == 0 {
- return 0.0
- }
- return smetrics.JaroWinkler(a, b, 0.7, 4)
-}
diff --git a/core/matcher/matcher_internal_test.go b/core/matcher/matcher_internal_test.go
deleted file mode 100644
index 5b987d937..000000000
--- a/core/matcher/matcher_internal_test.go
+++ /dev/null
@@ -1,258 +0,0 @@
-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"
-)
-
-var _ = Describe("similarityRatio", func() {
- It("returns 1.0 for identical strings", func() {
- Expect(similarityRatio("hello", "hello")).To(BeNumerically("==", 1.0))
- })
-
- It("returns 0.0 for empty strings", func() {
- Expect(similarityRatio("", "test")).To(BeNumerically("==", 0.0))
- Expect(similarityRatio("test", "")).To(BeNumerically("==", 0.0))
- })
-
- It("returns high similarity for remastered suffix", func() {
- ratio := similarityRatio("paranoid android", "paranoid android remastered")
- Expect(ratio).To(BeNumerically(">=", 0.85))
- })
-
- It("returns high similarity for suffix additions like (Live)", func() {
- ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live")
- Expect(ratio).To(BeNumerically(">=", 0.90))
- })
-
- It("returns high similarity for 'yesterday' variants (common prefix)", func() {
- ratio := similarityRatio("yesterday", "yesterday once more")
- Expect(ratio).To(BeNumerically(">=", 0.85))
- })
-
- It("returns low similarity for same suffix", func() {
- ratio := similarityRatio("postman (live)", "taxman (live)")
- Expect(ratio).To(BeNumerically("<", 0.85))
- })
-
- It("handles unicode characters", func() {
- ratio := similarityRatio("dont stop believin", "don't stop believin'")
- Expect(ratio).To(BeNumerically(">=", 0.85))
- })
-
- It("returns low similarity for completely different strings", func() {
- ratio := similarityRatio("abc", "xyz")
- Expect(ratio).To(BeNumerically("<", 0.5))
- })
-
- It("is symmetric", func() {
- ratio1 := similarityRatio("hello world", "hello")
- ratio2 := similarityRatio("hello", "hello world")
- 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
deleted file mode 100644
index 0f5389ca5..000000000
--- a/core/matcher/matcher_test.go
+++ /dev/null
@@ -1,1368 +0,0 @@
-package matcher_test
-
-import (
- "context"
- "errors"
- "strings"
-
- "github.com/Masterminds/squirrel"
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
- "github.com/navidrome/navidrome/core/agents"
- "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("Matcher", func() {
- var ds model.DataStore
- var mediaFileRepo *mockMediaFileRepo
- var artistRepo *mockArtistRepo
- var ctx context.Context
- var m *matcher.Matcher
-
- BeforeEach(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)
- })
-
- // Per-phase expectation helpers. Each `expect*Phase` registers a .Once() expectation
- // that will fail the suite via AssertExpectations if the phase is NOT called. Tests
- // use these to deterministically verify which matching phases fire. Phases that may
- // or may not fire should use the `allow*Phase` variants instead, which register
- // .Maybe() fallbacks.
- expectIDPhase := func(matches model.MediaFiles) {
- mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))).
- Return(matches, nil).Once()
- }
- expectMBIDPhase := func(matches model.MediaFiles) {
- mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))).
- Return(matches, nil).Once()
- }
- expectISRCPhase := func(matches model.MediaFiles) {
- mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))).
- 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() {
- 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())).
- 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()
- }
-
- Describe("MatchSongs", func() {
- Context("matching by direct ID", 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"}}},
- }
- idMatch := model.MediaFile{
- ID: "track-1", Title: "Some Song", Artist: "Some Artist",
- }
- expectIDPhase(model.MediaFiles{idMatch})
- allowOtherPhases()
- 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("matching by MBID", 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"}}},
- }
- mbidMatch := model.MediaFile{
- ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead",
- MbzRecordingID: "abc-123",
- }
- expectMBIDPhase(model.MediaFiles{mbidMatch})
- allowOtherPhases()
- result, err := m.MatchSongs(ctx, songs, 5)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("track-mbid"))
- })
- })
-
- Context("matching by ISRC", 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"}}},
- }
- isrcMatch := model.MediaFile{
- ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead",
- Tags: model.Tags{model.TagISRC: []string{"GBAYE0000351"}},
- }
- expectISRCPhase(model.MediaFiles{isrcMatch})
- allowOtherPhases()
- result, err := m.MatchSongs(ctx, songs, 5)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("track-isrc"))
- })
- })
-
- Context("fuzzy title+artist matching", 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"}}},
- }
- 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})
- result, err := m.MatchSongs(ctx, songs, 5)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("track-title"))
- })
-
- It("matches songs with fuzzy title similarity", func() {
- conf.Server.Matcher.FuzzyThreshold = 85
- songs := []agents.Song{
- {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}},
- }
- fuzzyMatch := model.MediaFile{
- ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
- Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}),
- }
- allowTitlePhase(model.MediaFiles{fuzzyMatch})
- result, err := m.MatchSongs(ctx, songs, 5)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("track-fuzzy"))
- })
-
- It("does not match completely different titles", func() {
- conf.Server.Matcher.FuzzyThreshold = 85
- songs := []agents.Song{
- {Name: "Yesterday", Artists: []agents.Artist{{Name: "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"}),
- },
- }
- allowTitlePhase(differentTracks)
- result, err := m.MatchSongs(ctx, songs, 5)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(BeEmpty())
- })
- })
-
- Context("deduplication", 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"}}},
- }
- 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})
- result, err := m.MatchSongs(ctx, songs, 5)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("br-live"))
- })
-
- 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"},
- }
- 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})
- result, err := m.MatchSongs(ctx, songs, 5)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(2))
- Expect(result[0].ID).To(Equal("br"))
- Expect(result[1].ID).To(Equal("br"))
- })
- })
-
- Context("priority ordering", func() {
- It("prefers ID match over MBID match", func() {
- conf.Server.Matcher.FuzzyThreshold = 100
- // Song has both ID and MBID set. The matcher should resolve via ID
- // 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"}}},
- }
- idMatch := model.MediaFile{
- ID: "track-id", Title: "Song", Artist: "Artist",
- }
- expectIDPhase(model.MediaFiles{idMatch})
- allowOtherPhases()
- result, err := m.MatchSongs(ctx, songs, 5)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("track-id"))
- })
- })
-
- Context("count limit", 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"}}},
- }
- 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"}),
- },
- }
- allowTitlePhase(tracks)
- result, err := m.MatchSongs(ctx, songs, 2)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(2))
- })
- })
-
- Context("empty input", func() {
- It("returns empty results for no songs", func() {
- result, err := m.MatchSongs(ctx, []agents.Song{}, 5)
- Expect(err).ToNot(HaveOccurred())
- 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"}}},
- }
- mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"}
- mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"}
-
- expectIDPhase(model.MediaFiles{mf1, mf2})
- allowOtherPhases()
-
- result, err := m.MatchSongsIndexed(ctx, songs)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(2))
- Expect(result[0].ID).To(Equal("track-1"))
- Expect(result[1].ID).To(Equal("track-2"))
- _, exists := result[2]
- Expect(exists).To(BeFalse())
- })
-
- 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"}}},
- }
- mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"}
-
- expectIDPhase(model.MediaFiles{mf1})
- allowOtherPhases()
-
- result, err := m.MatchSongsIndexed(ctx, songs)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- _, exists := result[0]
- Expect(exists).To(BeFalse())
- Expect(result[1].ID).To(Equal("track-1"))
- })
-
- It("returns empty map for empty input", func() {
- result, err := m.MatchSongsIndexed(ctx, nil)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(BeEmpty())
- })
- })
-
- Describe("specificity level matching", func() {
- BeforeEach(func() {
- conf.Server.Matcher.FuzzyThreshold = 100
- })
-
- It("matches by title + artist MBID + album MBID (highest priority)", 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"},
- }
-
- allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("correct-match"))
- })
-
- 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"},
- }
-
- allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("correct-match"))
- })
-
- 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"}}},
- }
-
- allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("correct-match"))
- })
-
- It("does not match songs without artist info", func() {
- songs := []agents.Song{
- {Name: "Similar Song"},
- }
-
- allowTitlePhase(model.MediaFiles{})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(BeEmpty())
- })
-
- 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"}),
- }
-
- songs := []agents.Song{
- {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"},
- {Name: "Yesterday", Artists: []agents.Artist{{Name: "Ray Charles"}}, Album: "Greatest Hits"},
- {Name: "Yesterday", Artists: []agents.Artist{{Name: "Frank Sinatra"}}, Album: "My Way"},
- }
-
- allowTitlePhase(model.MediaFiles{cover1, cover2, cover3})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(3))
- ids := []string{result[0].ID, result[1].ID, result[2].ID}
- Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
- })
-
- It("prefers more precise matches for each song", 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"}),
- }
- 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"}}},
- }
-
- allowTitlePhase(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(2))
- 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() {
- Context("with default threshold (85%)", func() {
- It("matches songs with remastered suffix", func() {
- conf.Server.Matcher.FuzzyThreshold = 85
-
- songs := []agents.Song{
- {Name: "Paranoid Android", Artists: []agents.Artist{{Name: "Radiohead"}}},
- }
- artistTracks := model.MediaFiles{
- {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead",
- Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}),
- },
- }
-
- allowTitlePhase(artistTracks)
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("remastered"))
- })
-
- It("matches songs with live suffix", func() {
- conf.Server.Matcher.FuzzyThreshold = 85
-
- songs := []agents.Song{
- {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}},
- }
- artistTracks := model.MediaFiles{
- {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
- Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}),
- },
- }
-
- allowTitlePhase(artistTracks)
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("live"))
- })
- })
-
- Context("with threshold set to 100 (exact match only)", func() {
- It("only matches exact titles", func() {
- conf.Server.Matcher.FuzzyThreshold = 100
-
- songs := []agents.Song{
- {Name: "Paranoid Android", Artists: []agents.Artist{{Name: "Radiohead"}}},
- }
- artistTracks := model.MediaFiles{
- {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead",
- Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}),
- },
- }
-
- allowTitlePhase(artistTracks)
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(BeEmpty())
- })
- })
-
- Context("with lower threshold (75%)", func() {
- It("matches more aggressively", func() {
- conf.Server.Matcher.FuzzyThreshold = 75
-
- songs := []agents.Song{
- {Name: "Song", Artists: []agents.Artist{{Name: "Artist"}}},
- }
- artistTracks := model.MediaFiles{
- {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist",
- Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}),
- },
- }
-
- allowTitlePhase(artistTracks)
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("extended"))
- })
- })
- })
-
- Describe("fuzzy album matching", func() {
- BeforeEach(func() {
- conf.Server.Matcher.FuzzyThreshold = 85
- conf.Server.Matcher.PreferStarred = false
- })
-
- It("matches album with (Remaster) suffix", func() {
- songs := []agents.Song{
- {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"},
- }
- correctMatch := model.MediaFile{
- ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
- Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}),
- }
- wrongMatch := model.MediaFile{
- ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
- Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}),
- }
-
- allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("correct"))
- })
-
- It("matches album with (Deluxe Edition) suffix", func() {
- songs := []agents.Song{
- {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"},
- }
- correctMatch := model.MediaFile{
- ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
- Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}),
- }
- wrongMatch := model.MediaFile{
- ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
- Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}),
- }
-
- allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("correct"))
- })
-
- 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"},
- }
- 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})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("exact"))
- })
-
- It("prefers a more specific match over a starred track when PreferStarred is enabled", func() {
- conf.Server.Matcher.PreferStarred = true
- songs := []agents.Song{
- {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"},
- }
- albumMatch := model.MediaFile{
- ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
- Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}),
- }
- starredTrack := model.MediaFile{
- ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles",
- Annotations: model.Annotations{Starred: true},
- Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}),
- }
- allowTitlePhase(model.MediaFiles{albumMatch, starredTrack})
- result, err := m.MatchSongs(ctx, songs, 5)
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("album-match")) // specificity now outranks the starred flag
- })
-
- It("prefers a more specific match over a 4-star track when PreferStarred is enabled", func() {
- conf.Server.Matcher.PreferStarred = true
- songs := []agents.Song{
- {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"},
- }
- albumMatch := model.MediaFile{
- ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
- Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}),
- }
- ratedTrack := model.MediaFile{
- ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles",
- Annotations: model.Annotations{Rating: 4},
- Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}),
- }
- 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})
- 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
- })
- })
-
- Describe("duration matching", func() {
- BeforeEach(func() {
- conf.Server.Matcher.FuzzyThreshold = 100
- })
-
- It("prefers tracks with matching duration", func() {
- songs := []agents.Song{
- {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000},
- }
- correctMatch := model.MediaFile{
- ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
- Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}),
- }
- wrongDuration := model.MediaFile{
- ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
- Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}),
- }
-
- allowTitlePhase(model.MediaFiles{wrongDuration, correctMatch})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("correct"))
- })
-
- It("matches tracks with close duration", func() {
- songs := []agents.Song{
- {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000},
- }
- closeDuration := model.MediaFile{
- ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
- Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}),
- }
-
- allowTitlePhase(model.MediaFiles{closeDuration})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("close-duration"))
- })
-
- It("prefers closer duration over farther duration", func() {
- songs := []agents.Song{
- {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000},
- }
- closeDuration := model.MediaFile{
- ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
- Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}),
- }
- farDuration := model.MediaFile{
- ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
- Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}),
- }
-
- allowTitlePhase(model.MediaFiles{farDuration, closeDuration})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("close"))
- })
-
- It("still matches when no tracks have matching duration", func() {
- songs := []agents.Song{
- {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000},
- }
- differentDuration := model.MediaFile{
- ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
- Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}),
- }
-
- allowTitlePhase(model.MediaFiles{differentDuration})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("different"))
- })
-
- 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},
- }
- 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})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("correct-title"))
- })
-
- 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},
- }
- 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})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(1))
- Expect(result[0].ID).To(Equal("any"))
- })
-
- It("handles very short songs with close duration", func() {
- songs := []agents.Song{
- {Name: "Short Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 30000},
- }
- shortTrack := model.MediaFile{
- ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
- Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}),
- }
-
- allowTitlePhase(model.MediaFiles{shortTrack})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- 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() {
- BeforeEach(func() {
- conf.Server.Matcher.FuzzyThreshold = 85
- })
-
- 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"},
- }
- 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})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(2))
- Expect(result[0].ID).To(Equal("yesterday"))
- Expect(result[1].ID).To(Equal("yesterday"))
- })
-
- 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"}),
- }
-
- allowTitlePhase(model.MediaFiles{trackA, trackB, trackC})
-
- result, err := m.MatchSongs(ctx, songs, 5)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(3))
- Expect(result[0].ID).To(Equal("track-a"))
- Expect(result[1].ID).To(Equal("track-b"))
- Expect(result[2].ID).To(Equal("track-c"))
- })
-
- 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"}),
- }
-
- allowTitlePhase(model.MediaFiles{trackA, trackB})
-
- result, err := m.MatchSongs(ctx, songs, 2)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(result).To(HaveLen(2))
- Expect(result[0].ID).To(Equal("track-a"))
- Expect(result[1].ID).To(Equal("track-b"))
- })
- })
-})
-
-type mockMediaFileRepo struct {
- mock.Mock
- model.MediaFileRepository
-}
-
-func newMockMediaFileRepo() *mockMediaFileRepo {
- return &mockMediaFileRepo{}
-}
-
-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)
-}
-
-func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
- return m.GetAll(ctx, options...)
-}
-
-func (m *mockMediaFileRepo) SetError(hasError bool) {
- if hasError {
- m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
- }
-}
-
-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 {
- return func(opt model.QueryOptions) bool {
- and, ok := opt.Filters.(squirrel.And)
- if !ok || len(and) < 2 {
- return false
- }
- eq, hasEq := and[0].(squirrel.Eq)
- if !hasEq {
- return false
- }
- _, hasField := eq[fieldName]
- return hasField
- }
-}
-
-// matchFieldInEq returns a matcher that checks whether QueryOptions.Filters is a
-// squirrel.Eq containing the given field name.
-func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool {
- return func(opt model.QueryOptions) bool {
- eq, ok := opt.Filters.(squirrel.Eq)
- if !ok {
- return false
- }
- _, hasField := eq[fieldName]
- 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/media_streamer.go b/core/media_streamer.go
new file mode 100644
index 000000000..b3593c4eb
--- /dev/null
+++ b/core/media_streamer.go
@@ -0,0 +1,214 @@
+package core
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "mime"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/core/ffmpeg"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/cache"
+)
+
+type MediaStreamer interface {
+ NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
+ DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
+}
+
+type TranscodingCache cache.FileCache
+
+func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
+ return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
+}
+
+type mediaStreamer struct {
+ ds model.DataStore
+ transcoder ffmpeg.FFmpeg
+ cache cache.FileCache
+}
+
+type streamJob struct {
+ ms *mediaStreamer
+ mf *model.MediaFile
+ filePath string
+ format string
+ bitRate int
+ offset int
+}
+
+func (j *streamJob) Key() string {
+ return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
+}
+
+func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
+ mf, err := ms.ds.MediaFile(ctx).Get(id)
+ if err != nil {
+ return nil, err
+ }
+
+ return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
+}
+
+func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
+ var format string
+ var bitRate int
+ var cached bool
+ defer func() {
+ log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
+ "bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
+ "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
+ }()
+
+ format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
+ s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
+ filePath := mf.AbsolutePath()
+
+ if format == "raw" {
+ log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
+ "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
+ "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
+ "selectedBitrate", bitRate, "selectedFormat", format)
+ f, err := os.Open(filePath)
+ if err != nil {
+ return nil, err
+ }
+ s.ReadCloser = f
+ s.Seeker = f
+ s.format = mf.Suffix
+ return s, nil
+ }
+
+ job := &streamJob{
+ ms: ms,
+ mf: mf,
+ filePath: filePath,
+ format: format,
+ bitRate: bitRate,
+ offset: reqOffset,
+ }
+ r, err := ms.cache.Get(ctx, job)
+ if err != nil {
+ log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
+ return nil, err
+ }
+ cached = r.Cached
+
+ s.ReadCloser = r
+ s.Seeker = r.Seeker
+
+ log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
+ "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
+ "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
+ "selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
+
+ return s, nil
+}
+
+type Stream struct {
+ ctx context.Context
+ mf *model.MediaFile
+ bitRate int
+ format string
+ io.ReadCloser
+ io.Seeker
+}
+
+func (s *Stream) Seekable() bool { return s.Seeker != nil }
+func (s *Stream) Duration() float32 { return s.mf.Duration }
+func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
+func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
+func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
+func (s *Stream) EstimatedContentLength() int {
+ return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
+}
+
+// TODO This function deserves some love (refactoring)
+func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
+ format = "raw"
+ if reqFormat == "raw" {
+ return format, 0
+ }
+ if reqFormat == mf.Suffix && reqBitRate == 0 {
+ bitRate = mf.BitRate
+ return format, bitRate
+ }
+ trc, hasDefault := request.TranscodingFrom(ctx)
+ var cFormat string
+ var cBitRate int
+ if reqFormat != "" {
+ cFormat = reqFormat
+ } else {
+ if hasDefault {
+ cFormat = trc.TargetFormat
+ cBitRate = trc.DefaultBitRate
+ if p, ok := request.PlayerFrom(ctx); ok {
+ cBitRate = p.MaxBitRate
+ }
+ } else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" {
+ // If no format is specified and no transcoding associated to the player, but a bitrate is specified,
+ // and there is no transcoding set for the player, we use the default downsampling format.
+ // But only if the requested bitRate is lower than the original bitRate.
+ log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat)
+ cFormat = conf.Server.DefaultDownsamplingFormat
+ }
+ }
+ if reqBitRate > 0 {
+ cBitRate = reqBitRate
+ }
+ if cBitRate == 0 && cFormat == "" {
+ return format, bitRate
+ }
+ t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
+ if err == nil {
+ format = t.TargetFormat
+ if cBitRate != 0 {
+ bitRate = cBitRate
+ } else {
+ bitRate = t.DefaultBitRate
+ }
+ }
+ if format == mf.Suffix && bitRate >= mf.BitRate {
+ format = "raw"
+ bitRate = 0
+ }
+ return format, bitRate
+}
+
+var (
+ onceTranscodingCache sync.Once
+ instanceTranscodingCache TranscodingCache
+)
+
+func GetTranscodingCache() TranscodingCache {
+ onceTranscodingCache.Do(func() {
+ instanceTranscodingCache = NewTranscodingCache()
+ })
+ return instanceTranscodingCache
+}
+
+func NewTranscodingCache() TranscodingCache {
+ return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
+ consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
+ func(ctx context.Context, arg cache.Item) (io.Reader, error) {
+ job := arg.(*streamJob)
+ t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
+ if err != nil {
+ log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
+ return nil, os.ErrInvalid
+ }
+ out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.filePath, job.bitRate, job.offset)
+ if err != nil {
+ log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
+ return nil, os.ErrInvalid
+ }
+ return out, nil
+ })
+}
diff --git a/core/media_streamer_Internal_test.go b/core/media_streamer_Internal_test.go
new file mode 100644
index 000000000..44fbf701c
--- /dev/null
+++ b/core/media_streamer_Internal_test.go
@@ -0,0 +1,162 @@
+package core
+
+import (
+ "context"
+
+ "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/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("MediaStreamer", func() {
+ var ds model.DataStore
+ ctx := log.NewContext(context.Background())
+
+ BeforeEach(func() {
+ ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
+ })
+
+ Context("selectTranscodingOptions", func() {
+ mf := &model.MediaFile{}
+ Context("player is not configured", func() {
+ It("returns raw if raw is requested", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
+ Expect(format).To(Equal("raw"))
+ })
+ It("returns raw if a transcoder does not exists", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
+ Expect(format).To(Equal("raw"))
+ })
+ It("returns the requested format if a transcoder exists", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
+ Expect(format).To(Equal("mp3"))
+ Expect(bitRate).To(Equal(160)) // Default Bit Rate
+ })
+ It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
+ mf.Suffix = "mp3"
+ mf.BitRate = 112
+ format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
+ Expect(format).To(Equal("raw"))
+ })
+ It("returns the requested format if requested BitRate is lower than original", func() {
+ mf.Suffix = "mp3"
+ mf.BitRate = 320
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
+ Expect(format).To(Equal("mp3"))
+ Expect(bitRate).To(Equal(192))
+ })
+ It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
+ mf.Suffix = "mp3"
+ mf.BitRate = 320
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
+ Expect(format).To(Equal("raw"))
+ Expect(bitRate).To(Equal(320))
+ })
+ Context("Downsampling", func() {
+ BeforeEach(func() {
+ conf.Server.DefaultDownsamplingFormat = "opus"
+ mf.Suffix = "FLAC"
+ mf.BitRate = 960
+ })
+ It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128)
+ Expect(format).To(Equal("opus"))
+ Expect(bitRate).To(Equal(128))
+ })
+ It("returns raw if maxBitrate is equal or greater than original", func() {
+ // This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960)
+ Expect(format).To(Equal("raw"))
+ Expect(bitRate).To(Equal(0))
+ })
+ })
+ })
+
+ Context("player has format configured", func() {
+ BeforeEach(func() {
+ t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
+ ctx = request.WithTranscoding(ctx, t)
+ })
+ It("returns raw if raw is requested", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
+ Expect(format).To(Equal("raw"))
+ })
+ It("returns configured format/bitrate as default", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
+ Expect(format).To(Equal("oga"))
+ Expect(bitRate).To(Equal(96))
+ })
+ It("returns requested format", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
+ Expect(format).To(Equal("mp3"))
+ Expect(bitRate).To(Equal(160)) // Default Bit Rate
+ })
+ It("returns requested bitrate", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
+ Expect(format).To(Equal("oga"))
+ Expect(bitRate).To(Equal(80))
+ })
+ It("returns raw if selected bitrate and format is the same as original", func() {
+ mf.Suffix = "mp3"
+ mf.BitRate = 192
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
+ Expect(format).To(Equal("raw"))
+ Expect(bitRate).To(Equal(0))
+ })
+ })
+
+ Context("player has maxBitRate configured", func() {
+ BeforeEach(func() {
+ t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
+ p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192}
+ ctx = request.WithTranscoding(ctx, t)
+ ctx = request.WithPlayer(ctx, p)
+ })
+ It("returns raw if raw is requested", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
+ Expect(format).To(Equal("raw"))
+ })
+ It("returns configured format/bitrate as default", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
+ Expect(format).To(Equal("oga"))
+ Expect(bitRate).To(Equal(192))
+ })
+ It("returns requested format", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
+ Expect(format).To(Equal("mp3"))
+ Expect(bitRate).To(Equal(160)) // Default Bit Rate
+ })
+ It("returns requested bitrate", func() {
+ mf.Suffix = "flac"
+ mf.BitRate = 1000
+ format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
+ Expect(format).To(Equal("oga"))
+ Expect(bitRate).To(Equal(160))
+ })
+ })
+ })
+})
diff --git a/core/media_streamer_test.go b/core/media_streamer_test.go
new file mode 100644
index 000000000..f5175495b
--- /dev/null
+++ b/core/media_streamer_test.go
@@ -0,0 +1,74 @@
+package core_test
+
+import (
+ "context"
+ "io"
+ "os"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
+ "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("MediaStreamer", func() {
+ var streamer core.MediaStreamer
+ var ds model.DataStore
+ ffmpeg := tests.NewMockFFmpeg("fake data")
+ ctx := log.NewContext(context.TODO())
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches")
+ conf.Server.TranscodingCacheSize = "100MB"
+ ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
+ ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
+ {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
+ })
+ testCache := core.NewTranscodingCache()
+ Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
+ streamer = core.NewMediaStreamer(ds, ffmpeg, testCache)
+ })
+ AfterEach(func() {
+ _ = os.RemoveAll(conf.Server.CacheFolder)
+ })
+
+ Context("NewStream", func() {
+ It("returns a seekable stream if format is 'raw'", func() {
+ s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(s.Seekable()).To(BeTrue())
+ })
+ It("returns a seekable stream if maxBitRate is 0", func() {
+ s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(s.Seekable()).To(BeTrue())
+ })
+ It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
+ s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(s.Seekable()).To(BeTrue())
+ })
+ It("returns a NON seekable stream if transcode is required", func() {
+ s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
+ Expect(err).To(BeNil())
+ Expect(s.Seekable()).To(BeFalse())
+ Expect(s.Duration()).To(Equal(float32(257.0)))
+ })
+ It("returns a seekable stream if the file is complete in the cache", func() {
+ s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
+ Expect(err).To(BeNil())
+ _, _ = io.ReadAll(s)
+ _ = s.Close()
+ Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
+
+ s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
+ Expect(err).To(BeNil())
+ Expect(s.Seekable()).To(BeTrue())
+ })
+ })
+})
diff --git a/core/metrics/insights.go b/core/metrics/insights.go
index 1d2ff5df4..f4f8738e7 100644
--- a/core/metrics/insights.go
+++ b/core/metrics/insights.go
@@ -6,12 +6,9 @@ import (
"encoding/json"
"math"
"net/http"
- "os"
"path/filepath"
"runtime"
"runtime/debug"
- "slices"
- "strings"
"sync"
"sync/atomic"
"time"
@@ -24,10 +21,7 @@ import (
"github.com/navidrome/navidrome/core/metrics/insights"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "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/plugins/schema"
"github.com/navidrome/navidrome/utils/singleton"
)
@@ -41,38 +35,37 @@ var (
)
type insightsCollector struct {
- ds model.DataStore
- lastRun atomic.Int64
- lastStatus atomic.Bool
+ ds model.DataStore
+ pluginLoader PluginLoader
+ lastRun atomic.Int64
+ lastStatus atomic.Bool
}
-func GetInstance(ds model.DataStore) Insights {
+// PluginLoader defines an interface for loading plugins
+type PluginLoader interface {
+ PluginList() map[string]schema.PluginManifest
+}
+
+func GetInstance(ds model.DataStore, pluginLoader PluginLoader) 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)
}
}
insightsID = id
- return &insightsCollector{ds: ds}
+ return &insightsCollector{ds: ds, pluginLoader: pluginLoader}
})
}
func (c *insightsCollector) Run(ctx context.Context) {
+ ctx = auth.WithAdminUser(ctx, c.ds)
for {
- // Refresh admin context on each iteration to handle cases where
- // admin user wasn't available on previous runs
- insightsCtx := auth.WithAdminUser(ctx, c.ds)
- u, _ := request.UserFrom(insightsCtx)
- if !u.IsAdmin {
- log.Trace(insightsCtx, "No admin user available, skipping insights collection")
- } else {
- c.sendInsights(insightsCtx)
- }
+ c.sendInsights(ctx)
select {
case <-time.After(consts.InsightsUpdateInterval):
continue
@@ -88,7 +81,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 +90,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
@@ -109,7 +104,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
return
}
req.Header.Set("Content-Type", "application/json")
- resp, err := hc.Do(req) //nolint:gosec
+ resp, err := hc.Do(req)
if err != nil {
log.Trace(ctx, "Could not send Insights data", err)
return
@@ -154,17 +149,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{
@@ -176,10 +160,6 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Build.Settings, data.Build.GoVersion = buildInfo()
data.OS.Containerized = consts.InContainer
- // Install info
- data.OS.Package = installedPackage()
- data.Platform = hostingPlatform()
-
// OS info
data.OS.Type = runtime.GOOS
data.OS.Arch = runtime.GOARCH
@@ -188,30 +168,27 @@ 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
- data.Config.CoverArtQuality = conf.Server.CoverArtQuality
- data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding
- data.Config.UICoverArtSize = conf.Server.UICoverArtSize
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
data.Config.EnableDownloads = conf.Server.EnableDownloads
data.Config.EnableSharing = conf.Server.EnableSharing
data.Config.EnableStarRating = conf.Server.EnableStarRating
data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != ""
+ data.Config.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != ""
data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
data.Config.EnableDeezer = conf.Server.Deezer.Enabled
data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
@@ -220,21 +197,18 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
- data.Config.SearchFullString = conf.Server.Search.FullString
- data.Config.SearchBackend = conf.Server.Search.Backend
+ data.Config.SearchFullString = conf.Server.SearchFullString
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
data.Config.PreferSortTags = conf.Server.PreferSortTags
data.Config.BackupSchedule = conf.Server.Backup.Schedule
data.Config.BackupCount = conf.Server.Backup.Count
data.Config.DevActivityPanel = conf.Server.DevActivityPanel
data.Config.ScannerEnabled = conf.Server.Scanner.Enabled
- data.Config.ScannerExtractor = conf.Server.Scanner.Extractor
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.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != ""
+ data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
return data
@@ -246,48 +220,40 @@ 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)
- if err != nil {
- log.Trace(ctx, "Error reading file suffixes count", err)
- }
// Check for smart playlists
data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)
@@ -302,7 +268,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 +295,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
@@ -337,16 +303,12 @@ func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error)
// collectPlugins collects information about installed plugins
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
- // TODO Fix import/inject cycles
- manager := plugins.GetManager(c.ds, events.GetBroker(), nil)
- info := manager.GetPluginInfo()
-
- result := make(map[string]insights.PluginInfo, len(info))
- for name, p := range info {
- result[name] = insights.PluginInfo{
- Name: p.Name,
- Version: p.Version,
+ plugins := make(map[string]insights.PluginInfo)
+ for id, manifest := range c.pluginLoader.PluginList() {
+ plugins[id] = insights.PluginInfo{
+ Name: manifest.Name,
+ Version: manifest.Version,
}
}
- return result
+ return plugins
}
diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go
index 8559d4204..105a6218e 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"`
@@ -18,7 +16,6 @@ type Data struct {
Containerized bool `json:"containerized"`
Arch string `json:"arch"`
NumCPU int `json:"numCPU"`
- Package string `json:"package,omitempty"`
} `json:"os"`
Mem struct {
Alloc uint64 `json:"alloc"`
@@ -42,49 +39,42 @@ type Data struct {
Libraries int64 `json:"libraries"`
ActiveUsers int64 `json:"activeUsers"`
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
- 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"`
+ 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"`
+ EnableSpotify bool `json:"enableSpotify,omitempty"`
+ EnableJukebox bool `json:"enableJukebox,omitempty"`
+ EnablePrometheus bool `json:"enablePrometheus,omitempty"`
+ EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
+ EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
+ SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
+ SearchFullString bool `json:"searchFullString,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/insights_linux.go b/core/metrics/insights_linux.go
index f37c945c1..dbf3c277c 100644
--- a/core/metrics/insights_linux.go
+++ b/core/metrics/insights_linux.go
@@ -42,7 +42,6 @@ type MountInfo struct {
var fsTypeMap = map[int64]string{
0x5346414f: "afs",
- 0x187: "autofs",
0x61756673: "aufs",
0x9123683E: "btrfs",
0xc36400: "ceph",
@@ -56,11 +55,9 @@ var fsTypeMap = map[int64]string{
0x6a656a63: "fakeowner", // FS inside a container
0x65735546: "fuse",
0x4244: "hfs",
- 0x482b: "hfs+",
0x9660: "iso9660",
0x3153464a: "jfs",
0x00006969: "nfs",
- 0x5346544e: "ntfs", // NTFS_SB_MAGIC
0x7366746e: "ntfs",
0x794c7630: "overlayfs",
0x9fa0: "proc",
@@ -72,16 +69,8 @@ var fsTypeMap = map[int64]string{
0x01021997: "v9fs",
0x786f4256: "vboxsf",
0x4d44: "vfat",
- 0xca451a4e: "virtiofs",
0x58465342: "xfs",
0x2FC12FC1: "zfs",
- 0x7c7c6673: "prlfs", // Parallels Shared Folders
-
- // Signed/unsigned conversion issues (negative hex values converted to uint32)
- -0x6edc97c2: "btrfs", // 0x9123683e
- -0x1acb2be: "smb2", // 0xfe534d42
- -0xacb2be: "cifs", // 0xff534d42
- -0xd0adff0: "f2fs", // 0xf2f52010
}
func getFilesystemType(path string) (string, error) {
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/mock_library_service.go b/core/mock_library_service.go
new file mode 100644
index 000000000..56f2abd4c
--- /dev/null
+++ b/core/mock_library_service.go
@@ -0,0 +1,46 @@
+package core
+
+import (
+ "context"
+
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+)
+
+// MockLibraryWrapper provides a simple wrapper around MockLibraryRepo
+// that implements the core.Library interface for testing
+type MockLibraryWrapper struct {
+ *tests.MockLibraryRepo
+}
+
+// MockLibraryRestAdapter adapts MockLibraryRepo to rest.Repository interface
+type MockLibraryRestAdapter struct {
+ *tests.MockLibraryRepo
+}
+
+// NewMockLibraryService creates a new mock library service for testing
+func NewMockLibraryService() Library {
+ repo := &tests.MockLibraryRepo{
+ Data: make(map[int]model.Library),
+ }
+ // Set up default test data
+ repo.SetData(model.Libraries{
+ {ID: 1, Name: "Test Library 1", Path: "/music/library1"},
+ {ID: 2, Name: "Test Library 2", Path: "/music/library2"},
+ })
+ return &MockLibraryWrapper{MockLibraryRepo: repo}
+}
+
+func (m *MockLibraryWrapper) NewRepository(ctx context.Context) rest.Repository {
+ return &MockLibraryRestAdapter{MockLibraryRepo: m.MockLibraryRepo}
+}
+
+// rest.Repository interface implementation
+
+func (a *MockLibraryRestAdapter) Delete(id string) error {
+ return a.DeleteByStringID(id)
+}
+
+var _ Library = (*MockLibraryWrapper)(nil)
+var _ rest.Repository = (*MockLibraryRestAdapter)(nil)
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..f356a1410 100644
--- a/core/playback/mpv/mpv.go
+++ b/core/playback/mpv/mpv.go
@@ -10,9 +10,9 @@ import (
"strings"
"sync"
+ "github.com/kballard/go-shellquote"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/utils/shellquote"
)
func start(ctx context.Context, args []string) (Executor, error) {
@@ -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/mpv_test.go b/core/playback/mpv/mpv_test.go
index 6754b39ac..20c02501b 100644
--- a/core/playback/mpv/mpv_test.go
+++ b/core/playback/mpv/mpv_test.go
@@ -14,7 +14,6 @@ import (
"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"
)
@@ -189,7 +188,7 @@ var _ = Describe("MPV", func() {
It("returns empty slice for empty template", func() {
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
- Expect(args).To(BeEmpty())
+ Expect(args).To(Equal([]string{}))
})
})
})
@@ -200,7 +199,6 @@ var _ = Describe("MPV", func() {
})
It("executes MPV command and captures arguments correctly", func() {
- tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -228,7 +226,6 @@ var _ = Describe("MPV", func() {
})
It("handles file paths with spaces", func() {
- tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -256,7 +253,6 @@ var _ = Describe("MPV", func() {
})
It("passes all snapcast arguments correctly", func() {
- tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
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..0c230a61f 100644
--- a/core/playback/queue.go
+++ b/core/playback/queue.go
@@ -3,7 +3,6 @@ package playback
import (
"fmt"
"math/rand"
- "strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -22,11 +21,11 @@ func NewQueue() *Queue {
}
func (pd *Queue) String() string {
- var filenames strings.Builder
+ filenames := ""
for idx, item := range pd.Items {
- filenames.WriteString(fmt.Sprint(idx) + ":" + item.Path + " ")
+ filenames += fmt.Sprint(idx) + ":" + item.Path + " "
}
- return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames.String())
+ return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
}
// returns the current mediafile or nil
@@ -100,7 +99,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.go b/core/playlists.go
new file mode 100644
index 000000000..f98179f88
--- /dev/null
+++ b/core/playlists.go
@@ -0,0 +1,392 @@
+package core
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/RaveNoX/go-jsoncommentstrip"
+ "github.com/bmatcuk/doublestar/v4"
+ "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/model/request"
+ "github.com/navidrome/navidrome/utils/ioutils"
+ "github.com/navidrome/navidrome/utils/slice"
+ "golang.org/x/text/unicode/norm"
+)
+
+type Playlists interface {
+ ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
+ Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
+ ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
+}
+
+type playlists struct {
+ ds model.DataStore
+}
+
+func NewPlaylists(ds model.DataStore) Playlists {
+ return &playlists{ds: ds}
+}
+
+func InPlaylistsPath(folder model.Folder) bool {
+ if conf.Server.PlaylistsPath == "" {
+ return true
+ }
+ rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
+ for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
+ if match, _ := doublestar.Match(path, rel); match {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
+ pls, err := s.parsePlaylist(ctx, filename, folder)
+ if err != nil {
+ log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
+ return nil, err
+ }
+ log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
+ err = s.updatePlaylist(ctx, pls)
+ if err != nil {
+ log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
+ }
+ return pls, err
+}
+
+func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
+ owner, _ := request.UserFrom(ctx)
+ pls := &model.Playlist{
+ OwnerID: owner.ID,
+ Public: false,
+ Sync: false,
+ }
+ err := s.parseM3U(ctx, pls, nil, reader)
+ if err != nil {
+ log.Error(ctx, "Error parsing playlist", err)
+ return nil, err
+ }
+ err = s.ds.Playlist(ctx).Put(pls)
+ if err != nil {
+ log.Error(ctx, "Error saving playlist", err)
+ return nil, err
+ }
+ return pls, nil
+}
+
+func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
+ pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
+ if err != nil {
+ return nil, err
+ }
+
+ file, err := os.Open(pls.Path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ reader := ioutils.UTF8Reader(file)
+ extension := strings.ToLower(filepath.Ext(playlistFile))
+ switch extension {
+ case ".nsp":
+ err = s.parseNSP(ctx, pls, reader)
+ default:
+ err = s.parseM3U(ctx, pls, folder, reader)
+ }
+ return pls, err
+}
+
+func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
+ playlistPath := filepath.Join(baseDir, playlistFile)
+ info, err := os.Stat(playlistPath)
+ if err != nil {
+ return nil, err
+ }
+
+ var extension = filepath.Ext(playlistFile)
+ var name = playlistFile[0 : len(playlistFile)-len(extension)]
+
+ pls := &model.Playlist{
+ Name: name,
+ Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
+ Public: false,
+ Path: playlistPath,
+ Sync: true,
+ UpdatedAt: info.ModTime(),
+ }
+ return pls, nil
+}
+
+func getPositionFromOffset(data []byte, offset int64) (line, column int) {
+ line = 1
+ for _, b := range data[:offset] {
+ if b == '\n' {
+ line++
+ column = 1
+ } else {
+ column++
+ }
+ }
+ return
+}
+
+func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
+ nsp := &nspFile{}
+ reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
+ reader = jsoncommentstrip.NewReader(reader)
+ input, err := io.ReadAll(reader)
+ if err != nil {
+ return fmt.Errorf("reading SmartPlaylist: %w", err)
+ }
+ err = json.Unmarshal(input, nsp)
+ if err != nil {
+ 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)
+ }
+ return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
+ }
+ pls.Rules = &nsp.Criteria
+ if nsp.Name != "" {
+ pls.Name = nsp.Name
+ }
+ if nsp.Comment != "" {
+ pls.Comment = nsp.Comment
+ }
+ return nil
+}
+
+func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
+ mediaFileRepository := s.ds.MediaFile(ctx)
+ var mfs model.MediaFiles
+ for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) {
+ filteredLines := make([]string, 0, len(lines))
+ for _, line := range lines {
+ line := strings.TrimSpace(line)
+ if strings.HasPrefix(line, "#PLAYLIST:") {
+ pls.Name = line[len("#PLAYLIST:"):]
+ continue
+ }
+ // Skip empty lines and extended info
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ if strings.HasPrefix(line, "file://") {
+ line = strings.TrimPrefix(line, "file://")
+ line, _ = url.QueryUnescape(line)
+ }
+ if !model.IsAudioFile(line) {
+ continue
+ }
+ filteredLines = append(filteredLines, line)
+ }
+ paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
+ if err != nil {
+ log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
+ continue
+ }
+ found, err := mediaFileRepository.FindByPaths(paths)
+ if err != nil {
+ log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
+ continue
+ }
+ existing := make(map[string]int, len(found))
+ for idx := range found {
+ existing[normalizePathForComparison(found[idx].Path)] = idx
+ }
+ for _, path := range paths {
+ idx, ok := existing[normalizePathForComparison(path)]
+ if ok {
+ mfs = append(mfs, found[idx])
+ } else {
+ log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path)
+ }
+ }
+ }
+ if pls.Name == "" {
+ pls.Name = time.Now().Format(time.RFC3339)
+ }
+ pls.Tracks = nil
+ pls.AddMediaFiles(mfs)
+
+ return nil
+}
+
+// normalizePathForComparison normalizes a file path to NFC form and converts to lowercase
+// for consistent comparison. This fixes Unicode normalization issues on macOS where
+// Apple Music creates playlists with NFC-encoded paths but the filesystem uses NFD.
+func normalizePathForComparison(path string) string {
+ return strings.ToLower(norm.NFC.String(path))
+}
+
+// TODO This won't work for multiple libraries
+func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
+ libRegex, err := s.compileLibraryPaths(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ res := make([]string, 0, len(lines))
+ for idx, line := range lines {
+ var libPath string
+ var filePath string
+
+ if folder != nil && !filepath.IsAbs(line) {
+ libPath = folder.LibraryPath
+ filePath = filepath.Join(folder.AbsolutePath(), line)
+ } else {
+ cleanLine := filepath.Clean(line)
+ if libPath = libRegex.FindString(cleanLine); libPath != "" {
+ filePath = cleanLine
+ }
+ }
+
+ if libPath != "" {
+ if rel, err := filepath.Rel(libPath, filePath); err == nil {
+ res = append(res, rel)
+ } else {
+ log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "libPath", libPath,
+ "filePath", filePath, err)
+ }
+ } else {
+ log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
+ }
+ }
+ return slice.Map(res, filepath.ToSlash), nil
+}
+
+func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
+ libs, err := s.ds.Library(ctx).GetAll()
+ if err != nil {
+ return nil, err
+ }
+
+ // Create regex patterns for each library path
+ patterns := make([]string, len(libs))
+ for i, lib := range libs {
+ cleanPath := filepath.Clean(lib.Path)
+ escapedPath := regexp.QuoteMeta(cleanPath)
+ patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
+ }
+ // Combine all patterns into a single regex
+ combinedPattern := strings.Join(patterns, "|")
+ re, err := regexp.Compile(combinedPattern)
+ if err != nil {
+ return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
+ }
+ return re, nil
+}
+
+func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
+ owner, _ := request.UserFrom(ctx)
+
+ pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ return err
+ }
+ if err == nil && !pls.Sync {
+ log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
+ return nil
+ }
+
+ if err == nil {
+ log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
+ newPls.ID = pls.ID
+ newPls.Name = pls.Name
+ newPls.Comment = pls.Comment
+ newPls.OwnerID = pls.OwnerID
+ newPls.Public = pls.Public
+ newPls.EvaluatedAt = &time.Time{}
+ } else {
+ log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
+ newPls.OwnerID = owner.ID
+ newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
+ }
+ return s.ds.Playlist(ctx).Put(newPls)
+}
+
+func (s *playlists) Update(ctx context.Context, playlistID string,
+ name *string, comment *string, public *bool,
+ idsToAdd []string, idxToRemove []int) error {
+ needsInfoUpdate := name != nil || comment != nil || public != nil
+ needsTrackRefresh := len(idxToRemove) > 0
+
+ return s.ds.WithTxImmediate(func(tx model.DataStore) error {
+ var pls *model.Playlist
+ var err error
+ repo := tx.Playlist(ctx)
+ tracks := repo.Tracks(playlistID, true)
+ if tracks == nil {
+ return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
+ }
+ if needsTrackRefresh {
+ pls, err = repo.GetWithTracks(playlistID, true, false)
+ pls.RemoveTracks(idxToRemove)
+ pls.AddMediaFilesByID(idsToAdd)
+ } else {
+ if len(idsToAdd) > 0 {
+ _, err = tracks.Add(idsToAdd)
+ if err != nil {
+ return err
+ }
+ }
+ if needsInfoUpdate {
+ pls, err = repo.Get(playlistID)
+ }
+ }
+ if err != nil {
+ return err
+ }
+ if !needsTrackRefresh && !needsInfoUpdate {
+ return nil
+ }
+
+ if name != nil {
+ pls.Name = *name
+ }
+ if comment != nil {
+ pls.Comment = *comment
+ }
+ if public != nil {
+ pls.Public = *public
+ }
+ // Special case: The playlist is now empty
+ if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
+ if err = tracks.DeleteAll(); err != nil {
+ return err
+ }
+ }
+ return repo.Put(pls)
+ })
+}
+
+type nspFile struct {
+ criteria.Criteria
+ Name string `json:"name"`
+ Comment string `json:"comment"`
+}
+
+func (i *nspFile) UnmarshalJSON(data []byte) error {
+ m := map[string]interface{}{}
+ err := json.Unmarshal(data, &m)
+ if err != nil {
+ return err
+ }
+ i.Name, _ = m["name"].(string)
+ i.Comment, _ = m["comment"].(string)
+ return json.Unmarshal(data, &i.Criteria)
+}
diff --git a/core/playlists/import.go b/core/playlists/import.go
deleted file mode 100644
index b5991b095..000000000
--- a/core/playlists/import.go
+++ /dev/null
@@ -1,225 +0,0 @@
-package playlists
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
-
- "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"
-)
-
-func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) {
- absPath, err := filepath.Abs(absolutePath)
- if err != nil {
- return nil, fmt.Errorf("resolving absolute path: %w", err)
- }
-
- dir := filepath.Dir(absPath)
- filename := filepath.Base(absPath)
-
- folder, err := s.resolveFolder(ctx, dir)
- if err != nil && !errors.Is(err, errNotInLibrary) {
- return nil, err
- }
- if err == nil {
- pls, err := s.importFromFolder(ctx, folder, filename, sync)
- if err != nil {
- return nil, err
- }
- if pls.ID != "" && pls.Sync != sync {
- pls.Sync = sync
- if putErr := s.ds.Playlist().Put(ctx, pls); putErr != nil {
- return nil, putErr
- }
- }
- return pls, nil
- }
-
- log.Debug(ctx, "Playlist file is outside all libraries, using path-based import", "path", absPath)
- pls, err := s.newSyncedPlaylist(dir, filename)
- if err != nil {
- return nil, fmt.Errorf("reading playlist file: %w", err)
- }
- pls.Sync = sync
-
- file, err := os.Open(absPath)
- if err != nil {
- return nil, fmt.Errorf("opening playlist file: %w", err)
- }
- defer file.Close()
-
- hasher := xxh3.New()
- reader := io.TeeReader(ioutils.UTF8Reader(file), hasher)
- 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
- }
- return pls, nil
-}
-
-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)
- if err != nil {
- return nil, err
- }
- matcher := model.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)
- if err != nil {
- return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err)
- }
- folder.LibraryPath = lib.Path
- return folder, nil
-}
-
-func (s *playlists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
- return s.importFromFolder(ctx, folder, filename, false)
-}
-
-func (s *playlists) importFromFolder(ctx context.Context, folder *model.Folder, filename string, forceSync bool) (*model.Playlist, error) {
- pls, err := s.parsePlaylist(ctx, filename, folder)
- if err != nil {
- log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
- return nil, err
- }
- log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
- err = s.updatePlaylist(ctx, pls, forceSync)
- if err != nil {
- log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
- }
- return pls, err
-}
-
-func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
- owner, _ := request.UserFrom(ctx)
- pls := &model.Playlist{
- OwnerID: owner.ID,
- Public: false,
- Sync: false,
- }
- err := s.parseM3U(ctx, pls, nil, reader)
- if err != nil {
- log.Error(ctx, "Error parsing playlist", err)
- return nil, err
- }
- err = s.ds.Playlist().Put(ctx, pls)
- if err != nil {
- log.Error(ctx, "Error saving playlist", err)
- return nil, err
- }
- return pls, nil
-}
-
-func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
- pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
- if err != nil {
- return nil, err
- }
-
- file, err := os.Open(pls.Path)
- if err != nil {
- return nil, err
- }
- 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)
- extension := strings.ToLower(filepath.Ext(playlistFile))
- switch extension {
- case ".nsp":
- err = s.parseNSP(ctx, pls, reader)
- 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())
-}
-
-// 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)
- 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)
- }
- }
- return pls, err
-}
-
-func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, forceSync bool) error {
- owner, _ := request.UserFrom(ctx)
-
- pls, err := s.findByPathNormalized(ctx, newPls.Path)
- if err != nil && !errors.Is(err, model.ErrNotFound) {
- return err
- }
- alreadyImportedAndNotSynced := err == nil && !pls.Sync && !forceSync
- if alreadyImportedAndNotSynced {
- log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
- return nil
- }
-
- 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
- newPls.Comment = pls.Comment
- 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
- }
- } else {
- log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
- newPls.OwnerID = owner.ID
- // For NSP files, Public may already be set from the file; for M3U, use server default
- if !newPls.IsSmartPlaylist() {
- newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
- }
- }
- return s.ds.Playlist().Put(ctx, newPls)
-}
diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go
deleted file mode 100644
index 25960f0fe..000000000
--- a/core/playlists/import_test.go
+++ /dev/null
@@ -1,1259 +0,0 @@
-package playlists_test
-
-import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "time"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
- "github.com/navidrome/navidrome/core/artwork"
- "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"
-)
-
-var _ = Describe("Playlists - Import", func() {
- var ds *tests.MockDataStore
- var ps playlists.Playlists
- var mockPlsRepo *tests.MockPlaylistRepo
- var mockLibRepo *tests.MockLibraryRepo
- ctx := context.Background()
-
- BeforeEach(func() {
- mockPlsRepo = tests.CreateMockPlaylistRepo()
- mockLibRepo = &tests.MockLibraryRepo{}
- ds = &tests.MockDataStore{
- MockedPlaylist: mockPlsRepo,
- MockedLibrary: mockLibRepo,
- }
- ctx = request.WithUser(ctx, model.User{ID: "123"})
- })
-
- Describe("ImportFromFolder", func() {
- var folder *model.Folder
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
- ds.MockedMediaFile = &mockedMediaFileRepo{}
- libPath, _ := os.Getwd()
- // Set up library with the actual library path that matches the folder
- mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
- folder = &model.Folder{
- ID: "1",
- LibraryID: 1,
- LibraryPath: libPath,
- Path: "tests/fixtures",
- Name: "playlists",
- }
- })
-
- Describe("M3U", func() {
- It("parses well-formed playlists", func() {
- pls, err := ps.ImportFromFolder(ctx, folder, "pls1.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.OwnerID).To(Equal("123"))
- Expect(pls.Tracks).To(HaveLen(2))
- Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
- Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
- Expect(mockPlsRepo.Last).To(Equal(pls))
- })
-
- It("parses playlists using LF ending", func() {
- pls, err := ps.ImportFromFolder(ctx, folder, "lf-ended.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(2))
- })
-
- It("parses playlists using CR ending (old Mac format)", func() {
- pls, err := ps.ImportFromFolder(ctx, folder, "cr-ended.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(2))
- })
-
- It("parses playlists with UTF-8 BOM marker", func() {
- pls, err := ps.ImportFromFolder(ctx, folder, "bom-test.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.OwnerID).To(Equal("123"))
- Expect(pls.Name).To(Equal("Test Playlist"))
- Expect(pls.Tracks).To(HaveLen(1))
- Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
- })
-
- It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() {
- pls, err := ps.ImportFromFolder(ctx, folder, "bom-test-utf16.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.OwnerID).To(Equal("123"))
- Expect(pls.Name).To(Equal("UTF-16 Test Playlist"))
- Expect(pls.Tracks).To(HaveLen(1))
- Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
- })
-
- It("parses #EXTALBUMARTURL with HTTP URL", func() {
- conf.Server.EnableM3UExternalAlbumArt = true
-
- pls, err := ps.ImportFromFolder(ctx, folder, "pls-with-art-url.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
- Expect(pls.Tracks).To(HaveLen(2))
- })
-
- It("parses #EXTALBUMARTURL with absolute local path", func() {
- tmpDir := GinkgoT().TempDir()
- imgPath := filepath.Join(tmpDir, "cover.jpg")
- Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
-
- m3u := fmt.Sprintf("#EXTALBUMARTURL:%s\ntest.mp3\ntest.ogg\n", imgPath)
- 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", "test.ogg"}}
- 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(Equal(imgPath))
- })
-
- It("parses #EXTALBUMARTURL with relative local path", func() {
- tmpDir := GinkgoT().TempDir()
- Expect(os.WriteFile(filepath.Join(tmpDir, "cover.jpg"), []byte("fake image"), 0600)).To(Succeed())
-
- m3u := "#EXTALBUMARTURL:cover.jpg\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))
-
- 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(Equal(filepath.Join(tmpDir, "cover.jpg")))
- })
-
- It("parses #EXTALBUMARTURL with file:// URL", func() {
- tmpDir := GinkgoT().TempDir()
- imgPath := filepath.Join(tmpDir, "my cover.jpg")
- Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
-
- m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", strings.ReplaceAll(imgPath, " ", "%20"))
- 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))
-
- 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(Equal(imgPath))
- })
-
- It("preserves + in file:// URLs (PathUnescape, not QueryUnescape)", func() {
- tmpDir := GinkgoT().TempDir()
- imgPath := filepath.Join(tmpDir, "A+B.jpg")
- Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
-
- m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", imgPath)
- 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))
-
- 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(Equal(imgPath))
- })
-
- It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() {
- tests.SkipOnWindows("relies on Unix /etc filesystem")
- tmpDir := GinkgoT().TempDir()
-
- m3u := "#EXTALBUMARTURL:/etc/passwd\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))
-
- 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 with file:// URL outside library boundaries", func() {
- tmpDir := GinkgoT().TempDir()
-
- m3u := "#EXTALBUMARTURL:file:///etc/passwd\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))
-
- 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 with relative path escaping library", func() {
- tmpDir := GinkgoT().TempDir()
-
- m3u := "#EXTALBUMARTURL:../../etc/passwd\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))
-
- 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))
-
- 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("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() {
- conf.Server.EnableM3UExternalAlbumArt = false
-
- tmpDir := GinkgoT().TempDir()
- m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\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))
-
- 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("updates ExternalImageURL on re-scan even when UploadedImage is set", func() {
- conf.Server.EnableM3UExternalAlbumArt = true
-
- 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))
-
- m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n"
- plsFile := filepath.Join(tmpDir, "test.m3u")
- Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
-
- existingPls := &model.Playlist{
- ID: "existing-id",
- Name: "Existing Playlist",
- Path: plsFile,
- Sync: true,
- UploadedImage: "existing-id.jpg",
- ExternalImageURL: "https://example.com/old-cover.jpg",
- }
- mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
-
- 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.UploadedImage).To(Equal("existing-id.jpg"))
- Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg"))
- })
-
- It("skips non-synced playlist on re-import (respects user's choice)", 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())
-
- existingPls := &model.Playlist{
- ID: "existing-id",
- Name: "Existing Playlist",
- Path: plsFile,
- Sync: false,
- OwnerID: "123",
- }
- mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
-
- plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
- pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
- Expect(err).ToNot(HaveOccurred())
- // updatePlaylist skips the non-synced playlist, so the returned
- // playlist has no ID (was never persisted/updated).
- 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))
-
- m3u := "test.mp3\n"
- plsFile := filepath.Join(tmpDir, "test.m3u")
- Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
-
- existingPls := &model.Playlist{
- ID: "existing-id",
- Name: "Existing Playlist",
- Path: plsFile,
- Sync: true,
- ExternalImageURL: "https://example.com/old-cover.jpg",
- }
- mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
-
- 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())
- })
- })
-
- Describe("NSP", func() {
- It("parses well-formed playlists", func() {
- pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp")
- Expect(err).ToNot(HaveOccurred())
- Expect(mockPlsRepo.Last).To(Equal(pls))
- Expect(pls.OwnerID).To(Equal("123"))
- Expect(pls.Name).To(Equal("Recently Played"))
- Expect(pls.Comment).To(Equal("Recently played tracks"))
- Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
- Expect(pls.Rules.Order).To(Equal("desc"))
- Expect(pls.Rules.Limit).To(Equal(100))
- Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
- })
- It("returns an error if the playlist is not well-formed", func() {
- tests.SkipOnWindows("line-ending differences affect JSON error offset")
- _, err := ps.ImportFromFolder(ctx, folder, "invalid_json.nsp")
- Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
- })
- It("parses NSP with public: true and creates public playlist", func() {
- pls, err := ps.ImportFromFolder(ctx, folder, "public_playlist.nsp")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Name).To(Equal("Public Playlist"))
- Expect(pls.Public).To(BeTrue())
- })
- It("parses NSP with public: false and creates private playlist", func() {
- pls, err := ps.ImportFromFolder(ctx, folder, "private_playlist.nsp")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Name).To(Equal("Private Playlist"))
- Expect(pls.Public).To(BeFalse())
- })
- It("uses server default when public field is absent", func() {
- conf.Server.DefaultPlaylistPublicVisibility = true
-
- pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp")
- Expect(err).ToNot(HaveOccurred())
- 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)",
- func(storedForm, filesystemForm string) {
- tests.SkipOnWindows("/tmp hardcoded in test")
- // Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301)
- plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed)
- plsNameNFD := norm.NFD.String(plsNameNFC)
- Expect(plsNameNFD).ToNot(Equal(plsNameNFC)) // Verify they differ
-
- nameByForm := map[string]string{"NFC": plsNameNFC, "NFD": plsNameNFD}
- storedName := nameByForm[storedForm]
- filesystemName := nameByForm[filesystemForm]
-
- tmpDir := GinkgoT().TempDir()
- mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
- ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
- ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
-
- // Create the playlist file on disk with the filesystem's normalization form
- plsFile := tmpDir + "/" + filesystemName + ".m3u"
- Expect(os.WriteFile(plsFile, []byte("#PLAYLIST:Test\n"), 0600)).To(Succeed())
-
- // Pre-populate mock repo with the stored normalization form
- storedPath := tmpDir + "/" + storedName + ".m3u"
- existingPls := &model.Playlist{
- ID: "existing-id",
- Name: "Existing Playlist",
- Path: storedPath,
- Sync: true,
- }
- mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
-
- // Import using the filesystem's normalization form
- plsFolder := &model.Folder{
- ID: "1",
- LibraryID: 1,
- LibraryPath: tmpDir,
- Path: "",
- Name: "",
- }
- pls, err := ps.ImportFromFolder(ctx, plsFolder, filesystemName+".m3u")
- Expect(err).ToNot(HaveOccurred())
-
- // Should update existing playlist, not create new one
- Expect(pls.ID).To(Equal("existing-id"))
- Expect(pls.Name).To(Equal("Existing Playlist"))
- },
- Entry("finds NFD-stored playlist when filesystem provides NFC path", "NFD", "NFC"),
- Entry("finds NFC-stored playlist when filesystem provides NFD path", "NFC", "NFD"),
- )
-
- Describe("Cross-library relative paths", func() {
- var tmpDir, plsDir, songsDir string
-
- BeforeEach(func() {
- // Create temp directory structure
- tmpDir = GinkgoT().TempDir()
- plsDir = tmpDir + "/playlists"
- songsDir = tmpDir + "/songs"
- Expect(os.Mkdir(plsDir, 0755)).To(Succeed())
- Expect(os.Mkdir(songsDir, 0755)).To(Succeed())
-
- // Setup two different libraries with paths matching our temp structure
- mockLibRepo.SetData([]model.Library{
- {ID: 1, Path: songsDir},
- {ID: 2, Path: plsDir},
- })
-
- // Create a mock media file repository that returns files for both libraries
- // Note: The paths are relative to their respective library roots
- ds.MockedMediaFile = &mockedMediaFileFromListRepo{
- data: []string{
- "abc.mp3", // This is songs/abc.mp3 relative to songsDir
- "def.mp3", // This is playlists/def.mp3 relative to plsDir
- },
- }
- ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
- })
-
- It("handles relative paths that reference files in other libraries", func() {
- // Create a temporary playlist file with relative path
- plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3"
- plsFile := plsDir + "/test.m3u"
- Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
-
- // Playlist is in the Playlists library folder
- // Important: Path should be relative to LibraryPath, and Name is the folder name
- plsFolder := &model.Folder{
- ID: "2",
- LibraryID: 2,
- LibraryPath: plsDir,
- Path: "",
- Name: "",
- }
-
- pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(2))
- Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
- Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library
- })
-
- It("ignores paths that point outside all libraries", func() {
- // Create a temporary playlist file with path outside libraries
- plsContent := "#PLAYLIST:Outside Test\n../../outside.mp3\nabc.mp3"
- plsFile := plsDir + "/test.m3u"
- Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
-
- plsFolder := &model.Folder{
- ID: "2",
- LibraryID: 2,
- LibraryPath: plsDir,
- Path: "",
- Name: "",
- }
-
- pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
- Expect(err).ToNot(HaveOccurred())
- // Should only find abc.mp3, not outside.mp3
- Expect(pls.Tracks).To(HaveLen(1))
- Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
- })
-
- It("handles relative paths with multiple '../' components", func() {
- // Create a nested structure: tmpDir/playlists/subfolder/test.m3u
- subFolder := plsDir + "/subfolder"
- Expect(os.Mkdir(subFolder, 0755)).To(Succeed())
-
- // Create the media file in the subfolder directory
- // The mock will return it as "def.mp3" relative to plsDir
- ds.MockedMediaFile = &mockedMediaFileFromListRepo{
- data: []string{
- "abc.mp3", // From songsDir library
- "def.mp3", // From plsDir library root
- },
- }
-
- // From subfolder, ../../songs/abc.mp3 should resolve to songs library
- // ../def.mp3 should resolve to plsDir/def.mp3
- plsContent := "#PLAYLIST:Nested Test\n../../songs/abc.mp3\n../def.mp3"
- plsFile := subFolder + "/test.m3u"
- Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
-
- // The folder: AbsolutePath = LibraryPath + Path + Name
- // So for /playlists/subfolder: LibraryPath=/playlists, Path="", Name="subfolder"
- plsFolder := &model.Folder{
- ID: "2",
- LibraryID: 2,
- LibraryPath: plsDir,
- Path: "", // Empty because subfolder is directly under library root
- Name: "subfolder", // The folder name
- }
-
- pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(2))
- Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
- Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library root
- })
-
- It("correctly resolves libraries when one path is a prefix of another", func() {
- // This tests the bug where /music would match before /music-classical
- // Create temp directory structure with prefix conflict
- tmpDir := GinkgoT().TempDir()
- musicDir := tmpDir + "/music"
- musicClassicalDir := tmpDir + "/music-classical"
- Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
- Expect(os.Mkdir(musicClassicalDir, 0755)).To(Succeed())
-
- // Setup two libraries where one is a prefix of the other
- mockLibRepo.SetData([]model.Library{
- {ID: 1, Path: musicDir}, // /tmp/xxx/music
- {ID: 2, Path: musicClassicalDir}, // /tmp/xxx/music-classical
- })
-
- // Mock will return tracks from both libraries
- ds.MockedMediaFile = &mockedMediaFileFromListRepo{
- data: []string{
- "rock.mp3", // From music library
- "bach.mp3", // From music-classical library
- },
- }
-
- // Create playlist in music library that references music-classical
- plsContent := "#PLAYLIST:Cross Prefix Test\nrock.mp3\n../music-classical/bach.mp3"
- plsFile := musicDir + "/test.m3u"
- Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
-
- plsFolder := &model.Folder{
- ID: "1",
- LibraryID: 1,
- LibraryPath: musicDir,
- Path: "",
- Name: "",
- }
-
- pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(2))
- Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
- Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!)
- })
-
- It("correctly handles identical relative paths from different libraries", func() {
- // This tests the bug where two libraries have files at the same relative path
- // and only one appears in the playlist
- tmpDir := GinkgoT().TempDir()
- musicDir := tmpDir + "/music"
- classicalDir := tmpDir + "/classical"
- Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
- Expect(os.Mkdir(classicalDir, 0755)).To(Succeed())
- Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed())
- Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed())
- // Create placeholder files so paths resolve correctly
- Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
- Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
-
- // Both libraries have a file at "album/track.mp3"
- mockLibRepo.SetData([]model.Library{
- {ID: 1, Path: musicDir},
- {ID: 2, Path: classicalDir},
- })
-
- // Mock returns files with same relative path but different IDs and library IDs
- // Keys use the library-qualified format: "libraryID:path"
- ds.MockedMediaFile = &mockedMediaFileRepo{
- data: map[string]model.MediaFile{
- "1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"},
- "2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"},
- },
- }
- // Recreate playlists service to pick up new mock
- ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
-
- // Create playlist in music library that references both tracks
- plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
- plsFile := musicDir + "/test.m3u"
- Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
-
- plsFolder := &model.Folder{
- ID: "1",
- LibraryID: 1,
- LibraryPath: musicDir,
- Path: "",
- Name: "",
- }
-
- pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
- Expect(err).ToNot(HaveOccurred())
-
- // Should have BOTH tracks, not just one
- Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
-
- // Verify we got tracks from DIFFERENT libraries (the key fix!)
- // Collect the library IDs
- libIDs := make(map[int]bool)
- for _, track := range pls.Tracks {
- libIDs[track.LibraryID] = true
- }
- Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries")
- Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1")
- Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2")
-
- // Both tracks should have the same relative path
- Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
- Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
- })
- })
- })
-
- Describe("ImportFile", func() {
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
- })
-
- It("resolves file inside a library and imports it", 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))
-
- plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n"
- plsFile := filepath.Join(tmpDir, "my-playlist.m3u")
- Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
-
- pls, err := ps.ImportFile(ctx, plsFile, true)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Name).To(Equal("My Playlist"))
- Expect(pls.Tracks).To(HaveLen(2))
- Expect(pls.Path).To(Equal(plsFile))
- Expect(pls.Sync).To(BeTrue())
- })
-
- It("records path for files outside all libraries", func() {
- tmpDir := GinkgoT().TempDir()
- 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))
-
- plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n"
- plsFile := filepath.Join(tmpDir, "external.m3u")
- Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
-
- pls, err := ps.ImportFile(ctx, plsFile, false)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Name).To(Equal("External Playlist"))
- Expect(pls.Path).To(Equal(plsFile))
- Expect(pls.Sync).To(BeFalse())
- })
-
- It("imports with Sync=false", 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))
-
- plsFile := filepath.Join(tmpDir, "test.m3u")
- Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
-
- pls, err := ps.ImportFile(ctx, plsFile, false)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Sync).To(BeFalse())
- })
-
- It("imports with Sync=true", 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))
-
- plsFile := filepath.Join(tmpDir, "test.m3u")
- Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
-
- pls, err := ps.ImportFile(ctx, plsFile, true)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Sync).To(BeTrue())
- })
-
- It("upgrades non-synced playlist to synced on re-import with sync=true", 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))
-
- plsFile := filepath.Join(tmpDir, "test.m3u")
- Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
-
- existingPls := &model.Playlist{
- ID: "existing-id", Name: "Existing", Path: plsFile,
- Sync: false, OwnerID: "123",
- }
- mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
-
- pls, err := ps.ImportFile(ctx, plsFile, true)
- Expect(err).ToNot(HaveOccurred())
- 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() {
- var repo *mockedMediaFileFromListRepo
- BeforeEach(func() {
- repo = &mockedMediaFileFromListRepo{}
- ds.MockedMediaFile = repo
- ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
- mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
- ctx = request.WithUser(ctx, model.User{ID: "123"})
- })
-
- It("parses well-formed playlists", func() {
- repo.data = []string{
- "tests/test.mp3",
- "tests/test.ogg",
- "tests/01 Invisible (RED) Edit Version.mp3",
- "downloads/newfile.flac",
- }
- m3u := strings.Join([]string{
- "#PLAYLIST:playlist 1",
- "/music/tests/test.mp3",
- "/music/tests/test.ogg",
- "/new/downloads/newfile.flac",
- "file:///music/tests/01%20Invisible%20(RED)%20Edit%20Version.mp3",
- }, "\n")
- f := strings.NewReader(m3u)
-
- pls, err := ps.ImportM3U(ctx, f)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.OwnerID).To(Equal("123"))
- Expect(pls.Name).To(Equal("playlist 1"))
- Expect(pls.Sync).To(BeFalse())
- Expect(pls.Tracks).To(HaveLen(4))
- Expect(pls.Tracks[0].Path).To(Equal("tests/test.mp3"))
- Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
- Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
- Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
- Expect(mockPlsRepo.Last).To(Equal(pls))
- })
-
- It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
- repo.data = []string{
- "tests/test.mp3",
- "tests/test.ogg",
- "/tests/01 Invisible (RED) Edit Version.mp3",
- }
- m3u := strings.Join([]string{
- "/music/tests/test.mp3",
- "/music/tests/test.ogg",
- }, "\n")
- f := strings.NewReader(m3u)
- pls, err := ps.ImportM3U(ctx, f)
- Expect(err).ToNot(HaveOccurred())
- _, err = time.Parse(time.RFC3339, pls.Name)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(2))
- })
-
- It("returns only tracks that exist in the database and in the same order as the m3u", func() {
- repo.data = []string{
- "album1/test1.mp3",
- "album2/test2.mp3",
- "album3/test3.mp3",
- }
- m3u := strings.Join([]string{
- "/music/album3/test3.mp3",
- "/music/album1/test1.mp3",
- "/music/album4/test4.mp3",
- "/music/album2/test2.mp3",
- }, "\n")
- f := strings.NewReader(m3u)
- pls, err := ps.ImportM3U(ctx, f)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(3))
- Expect(pls.Tracks[0].Path).To(Equal("album3/test3.mp3"))
- Expect(pls.Tracks[1].Path).To(Equal("album1/test1.mp3"))
- Expect(pls.Tracks[2].Path).To(Equal("album2/test2.mp3"))
- })
-
- It("is case-insensitive when comparing paths", func() {
- repo.data = []string{
- "abc/tEsT1.Mp3",
- }
- m3u := strings.Join([]string{
- "/music/ABC/TeSt1.mP3",
- }, "\n")
- f := strings.NewReader(m3u)
- pls, err := ps.ImportM3U(ctx, f)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(1))
- Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
- })
-
- It("parses #EXTALBUMARTURL with HTTP URL via ImportM3U", func() {
- conf.Server.EnableM3UExternalAlbumArt = true
-
- repo.data = []string{"tests/test.mp3"}
- m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\n/music/tests/test.mp3\n"
- pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
- })
-
- It("ignores relative #EXTALBUMARTURL when imported via API (no folder context)", func() {
- repo.data = []string{"tests/test.mp3"}
- m3u := "#EXTALBUMARTURL:cover.jpg\n/music/tests/test.mp3\n"
- pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
- Expect(err).ToNot(HaveOccurred())
- 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() {
- // Fullwidth uppercase ACROSS (U+FF21, U+FF23, U+FF32, U+FF2F, U+FF33, U+FF33)
- repo.data = []string{
- "plex/02 - ACROSS.flac",
- }
- m3u := "/music/plex/02 - ACROSS.flac\n"
- f := strings.NewReader(m3u)
- pls, err := ps.ImportM3U(ctx, f)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(1))
- Expect(pls.Tracks[0].Path).To(Equal("plex/02 - ACROSS.flac"))
- })
-
- // Unicode normalization tests: NFC (composed) vs NFD (decomposed) forms
- // macOS stores paths in NFD, Linux/Windows use NFC. Playlists may use either form.
- DescribeTable("matches paths across Unicode NFC/NFD normalization",
- func(description, pathNFC string, dbForm, playlistForm norm.Form) {
- pathNFD := norm.NFD.String(pathNFC)
- Expect(pathNFD).ToNot(Equal(pathNFC), "test path should have decomposable characters")
-
- // Set up DB with specified normalization form
- var dbPath string
- if dbForm == norm.NFC {
- dbPath = pathNFC
- } else {
- dbPath = pathNFD
- }
- repo.data = []string{dbPath}
-
- // Set up playlist with specified normalization form
- var playlistPath string
- if playlistForm == norm.NFC {
- playlistPath = pathNFC
- } else {
- playlistPath = pathNFD
- }
- m3u := "/music/" + playlistPath + "\n"
- f := strings.NewReader(m3u)
-
- pls, err := ps.ImportM3U(ctx, f)
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(1))
- Expect(pls.Tracks[0].Path).To(Equal(dbPath))
- },
- // French: è (U+00E8) decomposes to e + combining grave (U+0065 + U+0300)
- Entry("French diacritics - DB:NFD, playlist:NFC",
- "macOS DB with Apple Music playlist",
- "artist/Michèle/song.mp3", norm.NFD, norm.NFC),
-
- // Japanese Katakana: ド (U+30C9) decomposes to ト (U+30C8) + combining dakuten (U+3099)
- Entry("Japanese Katakana with dakuten - DB:NFC, playlist:NFC (#4884)",
- "Linux/Windows DB with NFC playlist",
- "artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFC, norm.NFC),
- Entry("Japanese Katakana with dakuten - DB:NFD, playlist:NFC (#4884)",
- "macOS DB with NFC playlist",
- "artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFD, norm.NFC),
-
- // Cyrillic: й (U+0439) decomposes to и (U+0438) + combining breve (U+0306)
- Entry("Cyrillic characters - DB:NFD, playlist:NFC (#4791)",
- "macOS DB with NFC playlist",
- "Жуки/Батарейка/01 - Разлюбила.mp3", norm.NFD, norm.NFC),
-
- // Polish: ó (U+00F3) decomposes to o + combining acute (U+0301)
- Entry("Polish diacritics - DB:NFD, playlist:NFC (#4663)",
- "macOS DB with NFC playlist",
- "Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFD, norm.NFC),
- Entry("Polish diacritics - DB:NFC, playlist:NFD",
- "Linux/Windows DB with macOS-exported playlist",
- "Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFC, norm.NFD),
- )
-
- })
-
- Describe("InPath", func() {
- var folder model.Folder
-
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- folder = model.Folder{
- LibraryPath: "/music",
- Path: "playlists/abc",
- Name: "folder1",
- }
- })
-
- It("returns true if PlaylistsPath is empty", func() {
- conf.Server.PlaylistsPath = ""
- Expect(playlists.InPath(folder)).To(BeTrue())
- })
-
- It("returns true if PlaylistsPath is any (**/**)", func() {
- conf.Server.PlaylistsPath = "**/**"
- Expect(playlists.InPath(folder)).To(BeTrue())
- })
-
- It("returns true if folder is in PlaylistsPath", func() {
- tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
- conf.Server.PlaylistsPath = "other/**:playlists/**"
- Expect(playlists.InPath(folder)).To(BeTrue())
- })
-
- It("returns false if folder is not in PlaylistsPath", func() {
- conf.Server.PlaylistsPath = "other"
- Expect(playlists.InPath(folder)).To(BeFalse())
- })
-
- It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
- conf.Server.PlaylistsPath = "."
- Expect(playlists.InPath(folder)).To(BeFalse())
-
- folder2 := model.Folder{
- LibraryPath: "/music",
- Path: "",
- Name: ".",
- }
-
- Expect(playlists.InPath(folder2)).To(BeTrue())
- })
- })
-})
-
-// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths.
-// If data map is provided, looks up files by key; otherwise creates them from paths.
-type mockedMediaFileRepo struct {
- model.MediaFileRepository
- data map[string]model.MediaFile
-}
-
-func (r *mockedMediaFileRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) {
- var mfs model.MediaFiles
-
- // If data map provided, look up files
- if r.data != nil {
- for _, path := range paths {
- if mf, ok := r.data[path]; ok {
- mfs = append(mfs, mf)
- }
- }
- return mfs, nil
- }
-
- // Otherwise, create MediaFiles from paths
- for idx, path := range paths {
- // Strip library qualifier if present (format: "libraryID:path")
- actualPath := path
- libraryID := 1
- if parts := strings.SplitN(path, ":", 2); len(parts) == 2 {
- if id, err := strconv.Atoi(parts[0]); err == nil {
- libraryID = id
- actualPath = parts[1]
- }
- }
-
- mfs = append(mfs, model.MediaFile{
- ID: strconv.Itoa(idx),
- Path: actualPath,
- LibraryID: libraryID,
- })
- }
- return mfs, nil
-}
-
-// mockedMediaFileFromListRepo's FindByPaths method returns a list of MediaFiles based on the data field
-type mockedMediaFileFromListRepo struct {
- model.MediaFileRepository
- data []string
-}
-
-func (r *mockedMediaFileFromListRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) {
- var mfs model.MediaFiles
-
- for idx, dataPath := range r.data {
- for _, requestPath := range paths {
- // Strip library qualifier if present (format: "libraryID:path")
- actualPath := requestPath
- libraryID := 1
- if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 {
- if id, err := strconv.Atoi(parts[0]); err == nil {
- libraryID = id
- actualPath = parts[1]
- }
- }
-
- // Case-insensitive comparison (like SQL's "collate nocase"), but with no
- // implicit Unicode normalization (SQLite does not normalize NFC/NFD).
- if strings.EqualFold(actualPath, dataPath) {
- mfs = append(mfs, model.MediaFile{
- ID: strconv.Itoa(idx),
- Path: dataPath, // Return original path from DB
- LibraryID: libraryID,
- })
- break
- }
- }
- }
- return mfs, nil
-}
-
-type mockFolderRepoForImport struct {
- model.FolderRepository
- folder *model.Folder
-}
-
-func (m *mockFolderRepoForImport) GetByPath(_ context.Context, _ 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
deleted file mode 100644
index ab95b8850..000000000
--- a/core/playlists/parse_m3u.go
+++ /dev/null
@@ -1,282 +0,0 @@
-package playlists
-
-import (
- "context"
- "fmt"
- "io"
- "net/url"
- "path/filepath"
- "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()
- 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.
- for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) {
- filteredLines := make([]string, 0, len(lines))
- for _, line := range lines {
- line := strings.TrimSpace(line)
- if after, ok := strings.CutPrefix(line, "#PLAYLIST:"); ok {
- pls.Name = after
- continue
- }
- if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
- owner, _ := request.UserFrom(ctx)
- pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner)
- continue
- }
- // Skip empty lines and extended info
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- if after, ok := strings.CutPrefix(line, "file://"); ok {
- line = after
- line, _ = url.PathUnescape(line)
- }
- if !model.IsAudioFile(line) {
- continue
- }
- filteredLines = append(filteredLines, line)
- }
- resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
- if err != nil {
- log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
- continue
- }
-
- // SQLite comparisons do not perform Unicode normalization, and filesystem normalization
- // differs across platforms (macOS often yields NFD, while Linux/Windows typically use NFC).
- // Generate lookup candidates for both forms so playlist entries match DB paths regardless
- // of the original normalization. See https://github.com/navidrome/navidrome/issues/4884
- //
- // We also include the original (non-lowercased) paths because SQLite's COLLATE NOCASE
- // only handles ASCII case-insensitivity. Non-ASCII characters like fullwidth letters
- // (e.g., ABCD vs abcd) are not matched case-insensitively by NOCASE.
- lookupCandidates := make([]string, 0, len(resolvedPaths)*4)
- seen := make(map[string]struct{}, len(resolvedPaths)*4)
- for _, path := range resolvedPaths {
- // Add original paths first (for exact matching of non-ASCII characters)
- nfcRaw := norm.NFC.String(path)
- if _, ok := seen[nfcRaw]; !ok {
- seen[nfcRaw] = struct{}{}
- lookupCandidates = append(lookupCandidates, nfcRaw)
- }
- nfdRaw := norm.NFD.String(path)
- if _, ok := seen[nfdRaw]; !ok {
- seen[nfdRaw] = struct{}{}
- lookupCandidates = append(lookupCandidates, nfdRaw)
- }
-
- // Add lowercased paths (for ASCII case-insensitive matching via NOCASE)
- nfc := strings.ToLower(nfcRaw)
- if _, ok := seen[nfc]; !ok {
- seen[nfc] = struct{}{}
- lookupCandidates = append(lookupCandidates, nfc)
- }
- nfd := strings.ToLower(nfdRaw)
- if _, ok := seen[nfd]; !ok {
- seen[nfd] = struct{}{}
- lookupCandidates = append(lookupCandidates, nfd)
- }
- }
-
- found, err := mediaFileRepository.FindByPaths(ctx, lookupCandidates)
- if err != nil {
- log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
- continue
- }
-
- // Build lookup map with library-qualified keys, normalized for comparison.
- // Canonicalize to NFC so NFD/NFC become comparable.
- existing := make(map[string]int, len(found))
- for idx := range found {
- key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(norm.NFC.String(found[idx].Path)))
- existing[key] = idx
- }
-
- // Find media files in the order of the resolved paths, to keep playlist order.
- // Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath",
- // so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant).
- for _, path := range resolvedPaths {
- key := strings.ToLower(norm.NFC.String(path))
- idx, ok := existing[key]
- if ok {
- mfs = append(mfs, found[idx])
- } else {
- // Prefer logging a composed representation when possible to avoid confusing output
- // with decomposed combining marks.
- log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", norm.NFC.String(path))
- }
- }
- }
- if pls.Name == "" {
- pls.Name = time.Now().Format(time.RFC3339)
- }
- pls.Tracks = nil
- pls.AddMediaFiles(mfs)
-
- return nil
-}
-
-// pathResolution holds the result of resolving a playlist path to a library-relative path.
-type pathResolution struct {
- absolutePath string
- libraryPath string
- libraryID int
- valid bool
-}
-
-// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
-// Format: "libraryID:relativePath" with forward slashes for path separators.
-func (r pathResolution) ToQualifiedString() (string, error) {
- if !r.valid {
- return "", fmt.Errorf("invalid path resolution")
- }
- relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
- if err != nil {
- return "", err
- }
- // Convert path separators to forward slashes
- return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
-}
-
-// pathResolver handles path resolution logic for playlist imports.
-type pathResolver struct {
- matcher *model.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)
- if err != nil {
- return nil, err
- }
- matcher := model.NewLibraryMatcher(libs)
- return &pathResolver{matcher: matcher}, nil
-}
-
-// resolvePath determines the absolute path and library path for a playlist entry.
-// For absolute paths, it uses them directly.
-// For relative paths, it resolves them relative to the playlist's folder location.
-// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
-//
-// resolves to /music/songs/abc.mp3
-func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
- var absolutePath string
- if folder != nil && !filepath.IsAbs(line) {
- // Resolve relative path to absolute path based on playlist location
- absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
- } else {
- // Use absolute path directly after cleaning
- absolutePath = filepath.Clean(line)
- }
-
- return r.findInLibraries(absolutePath)
-}
-
-// findInLibraries matches an absolute path against all known libraries and returns
-// 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 {
- return pathResolution{valid: false}
- }
- return pathResolution{
- absolutePath: absolutePath,
- libraryPath: filepath.Clean(lib.Path),
- libraryID: lib.ID,
- valid: true,
- }
-}
-
-// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
-// For relative paths, it resolves them to absolute paths first, then determines which
-// library they belong to. This allows playlists to reference files across library boundaries.
-func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
- results := make([]string, 0, len(lines))
- for idx, line := range lines {
- resolution := r.resolvePath(line, folder)
-
- if !resolution.valid {
- log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
- continue
- }
-
- qualifiedPath, err := resolution.ToQualifiedString()
- if err != nil {
- log.Debug(ctx, "Error getting library-qualified path", "path", line,
- "libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
- continue
- }
-
- results = append(results, qualifiedPath)
- }
-
- return results, nil
-}
-
-// resolveImageURL resolves an #EXTALBUMARTURL value to a storable string.
-// 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 {
- value = strings.TrimSpace(value)
- if value == "" {
- return ""
- }
-
- // HTTP(S) URLs — store as-is, but only if external album art is enabled
- if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
- if !conf.Server.EnableM3UExternalAlbumArt {
- return ""
- }
- return value
- }
-
- // Resolve to local absolute path
- localPath, ok := resolveLocalPath(value, folder)
- if !ok || !model.IsImageFile(localPath) {
- 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)) {
- return ""
- }
- return localPath
-}
-
-// resolveLocalPath converts a file://, absolute, or relative path to a clean absolute path.
-// Returns ("", false) if the path cannot be resolved.
-func resolveLocalPath(value string, folder *model.Folder) (string, bool) {
- if after, ok := strings.CutPrefix(value, "file://"); ok {
- decoded, err := url.PathUnescape(after)
- if err != nil {
- return "", false
- }
- return filepath.Clean(decoded), true
- }
- if filepath.IsAbs(value) {
- return filepath.Clean(value), true
- }
- if folder == nil {
- return "", false
- }
- return filepath.Clean(filepath.Join(folder.AbsolutePath(), value)), true
-}
diff --git a/core/playlists/parse_m3u_test.go b/core/playlists/parse_m3u_test.go
deleted file mode 100644
index ced6c2b16..000000000
--- a/core/playlists/parse_m3u_test.go
+++ /dev/null
@@ -1,227 +0,0 @@
-package playlists
-
-import (
- "context"
-
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("pathResolver", func() {
- var ds *tests.MockDataStore
- var mockLibRepo *tests.MockLibraryRepo
- var resolver *pathResolver
- ctx := context.Background()
-
- BeforeEach(func() {
- tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
- mockLibRepo = &tests.MockLibraryRepo{}
- ds = &tests.MockDataStore{
- MockedLibrary: mockLibRepo,
- }
-
- // Setup test libraries
- mockLibRepo.SetData([]model.Library{
- {ID: 1, Path: "/music"},
- {ID: 2, Path: "/music-classical"},
- {ID: 3, Path: "/podcasts"},
- })
-
- var err error
- resolver, err = newPathResolver(ctx, ds)
- Expect(err).ToNot(HaveOccurred())
- })
-
- Describe("resolvePath", func() {
- Context("basic", func() {
- It("resolves absolute paths", func() {
- resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
-
- Expect(resolution.valid).To(BeTrue())
- Expect(resolution.libraryID).To(Equal(1))
- Expect(resolution.libraryPath).To(Equal("/music"))
- Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
- })
-
- It("resolves relative paths when folder is provided", func() {
- folder := &model.Folder{
- Path: "playlists",
- LibraryPath: "/music",
- LibraryID: 1,
- }
-
- resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
-
- Expect(resolution.valid).To(BeTrue())
- Expect(resolution.libraryID).To(Equal(1))
- Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
- })
-
- It("returns invalid resolution for paths outside any library", func() {
- resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
-
- Expect(resolution.valid).To(BeFalse())
- })
- })
-
- Context("cross-library", func() {
- It("resolves path within a library", func() {
- resolution := resolver.resolvePath("/music/track.mp3", nil)
-
- Expect(resolution.valid).To(BeTrue())
- Expect(resolution.libraryID).To(Equal(1))
- Expect(resolution.libraryPath).To(Equal("/music"))
- Expect(resolution.absolutePath).To(Equal("/music/track.mp3"))
- })
-
- It("resolves path to the longest matching library", func() {
- resolution := resolver.resolvePath("/music-classical/track.mp3", nil)
-
- Expect(resolution.valid).To(BeTrue())
- Expect(resolution.libraryID).To(Equal(2))
- Expect(resolution.libraryPath).To(Equal("/music-classical"))
- })
-
- It("returns invalid resolution for path outside libraries", func() {
- resolution := resolver.resolvePath("/videos/movie.mp4", nil)
-
- Expect(resolution.valid).To(BeFalse())
- })
-
- It("cleans the path before matching", func() {
- resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil)
-
- Expect(resolution.valid).To(BeTrue())
- Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
- })
- })
-
- Context("With relative paths", func() {
- It("resolves relative path within same library", func() {
- folder := &model.Folder{
- Path: "playlists",
- LibraryPath: "/music",
- LibraryID: 1,
- }
-
- resolution := resolver.resolvePath("../songs/track.mp3", folder)
-
- Expect(resolution.valid).To(BeTrue())
- Expect(resolution.libraryID).To(Equal(1))
- Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3"))
- })
-
- It("resolves relative path to different library", func() {
- folder := &model.Folder{
- Path: "playlists",
- LibraryPath: "/music",
- LibraryID: 1,
- }
-
- // Path goes up and into a different library
- resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder)
-
- Expect(resolution.valid).To(BeTrue())
- Expect(resolution.libraryID).To(Equal(3))
- Expect(resolution.libraryPath).To(Equal("/podcasts"))
- })
-
- It("uses matcher to find correct library for resolved path", func() {
- folder := &model.Folder{
- Path: "playlists",
- LibraryPath: "/music",
- LibraryID: 1,
- }
-
- // This relative path resolves to music-classical library
- resolution := resolver.resolvePath("../../music-classical/track.mp3", folder)
-
- Expect(resolution.valid).To(BeTrue())
- Expect(resolution.libraryID).To(Equal(2))
- Expect(resolution.libraryPath).To(Equal("/music-classical"))
- })
-
- It("returns invalid for relative paths escaping all libraries", func() {
- folder := &model.Folder{
- Path: "playlists",
- LibraryPath: "/music",
- LibraryID: 1,
- }
-
- resolution := resolver.resolvePath("../../../../etc/passwd", folder)
-
- Expect(resolution.valid).To(BeFalse())
- })
- })
- })
-
- Describe("Cross-library resolution scenarios", func() {
- It("handles playlist in library A referencing file in library B", func() {
- // Playlist is in /music/playlists
- folder := &model.Folder{
- Path: "playlists",
- LibraryPath: "/music",
- LibraryID: 1,
- }
-
- // Relative path that goes to /podcasts library
- resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder)
-
- Expect(resolution.valid).To(BeTrue())
- Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library")
- Expect(resolution.libraryPath).To(Equal("/podcasts"))
- })
-
- It("prefers longer library paths when resolving", func() {
- // Ensure /music-classical is matched instead of /music
- resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil)
-
- Expect(resolution.valid).To(BeTrue())
- Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music")
- })
- })
-})
-
-var _ = Describe("pathResolution", func() {
- Describe("ToQualifiedString", func() {
- It("converts valid resolution to qualified string with forward slashes", func() {
- resolution := pathResolution{
- absolutePath: "/music/artist/album/track.mp3",
- libraryPath: "/music",
- libraryID: 1,
- valid: true,
- }
-
- qualifiedStr, err := resolution.ToQualifiedString()
-
- Expect(err).ToNot(HaveOccurred())
- Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3"))
- })
-
- It("handles Windows-style paths by converting to forward slashes", func() {
- resolution := pathResolution{
- absolutePath: "/music/artist/album/track.mp3",
- libraryPath: "/music",
- libraryID: 2,
- valid: true,
- }
-
- qualifiedStr, err := resolution.ToQualifiedString()
-
- Expect(err).ToNot(HaveOccurred())
- // Should always use forward slashes regardless of OS
- Expect(qualifiedStr).To(ContainSubstring("2:"))
- Expect(qualifiedStr).ToNot(ContainSubstring("\\"))
- })
-
- It("returns error for invalid resolution", func() {
- resolution := pathResolution{valid: false}
-
- _, err := resolution.ToQualifiedString()
-
- Expect(err).To(HaveOccurred())
- })
- })
-})
diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go
deleted file mode 100644
index a5b8b7c02..000000000
--- a/core/playlists/parse_nsp.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package playlists
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "os"
- "path/filepath"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/criteria"
- "github.com/navidrome/navidrome/utils/jsoncommentstrip"
-)
-
-func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
- playlistPath := filepath.Join(baseDir, playlistFile)
- info, err := os.Stat(playlistPath)
- if err != nil {
- return nil, err
- }
-
- var extension = filepath.Ext(playlistFile)
- var name = playlistFile[0 : len(playlistFile)-len(extension)]
-
- pls := &model.Playlist{
- Name: name,
- Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
- Public: false,
- Path: playlistPath,
- Sync: true,
- UpdatedAt: info.ModTime(),
- }
- return pls, nil
-}
-
-func getPositionFromOffset(data []byte, offset int64) (line, column int) {
- line = 1
- for _, b := range data[:offset] {
- if b == '\n' {
- line++
- column = 1
- } else {
- column++
- }
- }
- return
-}
-
-func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
- nsp := &nspFile{}
- reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
- reader = jsoncommentstrip.NewReader(reader)
- input, err := io.ReadAll(reader)
- if err != nil {
- return fmt.Errorf("reading SmartPlaylist: %w", err)
- }
- err = json.Unmarshal(input, nsp)
- if err != nil {
- if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok {
- line, col := getPositionFromOffset(input, syntaxErr.Offset)
- return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
- }
- return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
- }
- pls.Rules = &nsp.Criteria
- if nsp.Name != "" {
- pls.Name = nsp.Name
- }
- if nsp.Comment != "" {
- pls.Comment = nsp.Comment
- }
- if nsp.Public != nil {
- pls.Public = *nsp.Public
- } else {
- pls.Public = conf.Server.DefaultPlaylistPublicVisibility
- }
- return nil
-}
-
-type nspFile struct {
- criteria.Criteria
- Name string `json:"name"`
- Comment string `json:"comment"`
- Public *bool `json:"public"`
-}
-
-func (i *nspFile) UnmarshalJSON(data []byte) error {
- m := map[string]any{}
- err := json.Unmarshal(data, &m)
- if err != nil {
- return err
- }
- i.Name, _ = m["name"].(string)
- i.Comment, _ = m["comment"].(string)
- if public, ok := m["public"].(bool); ok {
- i.Public = &public
- }
- return json.Unmarshal(data, &i.Criteria)
-}
diff --git a/core/playlists/parse_nsp_test.go b/core/playlists/parse_nsp_test.go
deleted file mode 100644
index d6d69866f..000000000
--- a/core/playlists/parse_nsp_test.go
+++ /dev/null
@@ -1,242 +0,0 @@
-package playlists
-
-import (
- "context"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/criteria"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("parseNSP", func() {
- var s *playlists
- ctx := context.Background()
-
- BeforeEach(func() {
- s = &playlists{}
- })
-
- It("parses a well-formed NSP with all fields", func() {
- nsp := `{
- "name": "My Smart Playlist",
- "comment": "A test playlist",
- "public": true,
- "all": [{"is": {"loved": true}}],
- "sort": "title",
- "order": "asc",
- "limit": 50
- }`
- pls := &model.Playlist{Name: "default-name"}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Name).To(Equal("My Smart Playlist"))
- Expect(pls.Comment).To(Equal("A test playlist"))
- Expect(pls.Public).To(BeTrue())
- Expect(pls.Rules).ToNot(BeNil())
- Expect(pls.Rules.Sort).To(Equal("title"))
- Expect(pls.Rules.Order).To(Equal("asc"))
- Expect(pls.Rules.Limit).To(Equal(50))
- Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
- })
-
- It("keeps existing name when NSP has no name field", func() {
- nsp := `{"all": [{"is": {"loved": true}}]}`
- pls := &model.Playlist{Name: "Original Name"}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Name).To(Equal("Original Name"))
- })
-
- It("keeps existing comment when NSP has no comment field", func() {
- nsp := `{"all": [{"is": {"loved": true}}]}`
- pls := &model.Playlist{Comment: "Original Comment"}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Comment).To(Equal("Original Comment"))
- })
-
- It("strips JSON comments before parsing", func() {
- nsp := `{
- // Line comment
- "name": "Commented Playlist",
- /* Block comment */
- "all": [{"is": {"loved": true}}]
- }`
- pls := &model.Playlist{}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Name).To(Equal("Commented Playlist"))
- })
-
- It("uses server default when public field is absent", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.DefaultPlaylistPublicVisibility = true
-
- nsp := `{"all": [{"is": {"loved": true}}]}`
- pls := &model.Playlist{}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Public).To(BeTrue())
- })
-
- It("honors explicit public: false over server default", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.DefaultPlaylistPublicVisibility = true
-
- nsp := `{"public": false, "all": [{"is": {"loved": true}}]}`
- pls := &model.Playlist{}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Public).To(BeFalse())
- })
-
- It("returns a syntax error with line and column info", func() {
- nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}"
- pls := &model.Playlist{}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist"))
- Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`))
- })
-
- It("returns a parsing error for completely invalid JSON", func() {
- nsp := `not json at all`
- pls := &model.Playlist{}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).To(HaveOccurred())
- 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"}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).ToNot(HaveOccurred())
- // Type assertion in UnmarshalJSON fails silently; name stays as original
- Expect(pls.Name).To(Equal("Original"))
- })
-
- It("parses limitPercent from NSP", func() {
- nsp := `{
- "all": [{"is": {"loved": true}}],
- "sort": "playCount",
- "order": "desc",
- "limitPercent": 25
- }`
- pls := &model.Playlist{}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Rules).ToNot(BeNil())
- Expect(pls.Rules.LimitPercent).To(Equal(25))
- Expect(pls.Rules.Limit).To(Equal(0))
- })
-
- It("parses criteria with multiple rules", func() {
- nsp := `{
- "all": [
- {"is": {"loved": true}},
- {"contains": {"title": "rock"}}
- ],
- "sort": "lastPlayed",
- "order": "desc",
- "limit": 100
- }`
- pls := &model.Playlist{}
- err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Rules).ToNot(BeNil())
- Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
- Expect(pls.Rules.Order).To(Equal("desc"))
- Expect(pls.Rules.Limit).To(Equal(100))
- })
-})
-
-var _ = Describe("getPositionFromOffset", func() {
- It("returns correct position on first line", func() {
- data := []byte("hello world")
- line, col := getPositionFromOffset(data, 5)
- Expect(line).To(Equal(1))
- Expect(col).To(Equal(5))
- })
-
- It("returns correct position after newlines", func() {
- data := []byte("line1\nline2\nline3")
- // Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8)
- line, col := getPositionFromOffset(data, 8)
- Expect(line).To(Equal(2))
- Expect(col).To(Equal(3))
- })
-
- It("returns correct position at start of new line", func() {
- data := []byte("line1\nline2")
- // After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1
- line, col := getPositionFromOffset(data, 6)
- Expect(line).To(Equal(2))
- Expect(col).To(Equal(1))
- })
-
- It("handles multiple newlines", func() {
- data := []byte("a\nb\nc\nd")
- // a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6)
- line, col := getPositionFromOffset(data, 6)
- Expect(line).To(Equal(4))
- Expect(col).To(Equal(1))
- })
-})
-
-var _ = Describe("newSyncedPlaylist", func() {
- var s *playlists
-
- BeforeEach(func() {
- s = &playlists{}
- })
-
- It("creates a synced playlist with correct attributes", func() {
- tmpDir := GinkgoT().TempDir()
- Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed())
-
- pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Name).To(Equal("test"))
- Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'"))
- Expect(pls.Public).To(BeFalse())
- Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u")))
- Expect(pls.Sync).To(BeTrue())
- Expect(pls.UpdatedAt).ToNot(BeZero())
- })
-
- It("strips extension from filename to derive name", func() {
- tmpDir := GinkgoT().TempDir()
- Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed())
-
- pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp")
- Expect(err).ToNot(HaveOccurred())
- Expect(pls.Name).To(Equal("My Favorites"))
- })
-
- It("returns error for non-existent file", func() {
- tmpDir := GinkgoT().TempDir()
- _, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u")
- Expect(err).To(HaveOccurred())
- })
-})
diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go
deleted file mode 100644
index c9bc03b97..000000000
--- a/core/playlists/playlists.go
+++ /dev/null
@@ -1,368 +0,0 @@
-package playlists
-
-import (
- "context"
- "io"
- "os"
- "path/filepath"
- "strconv"
- "strings"
-
- "github.com/bmatcuk/doublestar/v4"
- "github.com/deluan/rest"
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/consts"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
-)
-
-type Playlists interface {
- // Reads
- 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
- Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
- Delete(ctx context.Context, id string) error
- Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
-
- // 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)
- RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
- ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
-
- // Cover art
- SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error
- RemoveImage(ctx context.Context, playlistID string) error
-
- // Import
- ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error)
- ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
- 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]
-}
-
-// ImageUploadService is a local interface satisfied by artwork.Uploader.
-// Defined here to avoid an import cycle between core/artwork 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
-}
-
-func InPath(folder model.Folder) bool {
- if conf.Server.PlaylistsPath == "" {
- return true
- }
- rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
- for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
- if match, _ := doublestar.Match(path, rel); match {
- return true
- }
- }
- return false
-}
-
-// --- Read operations ---
-
-func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
- return s.ds.Playlist().GetAll(ctx, options...)
-}
-
-func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
- return s.ds.Playlist().Get(ctx, id)
-}
-
-func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
- return s.ds.Playlist().GetWithTracks(ctx, 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
-}
-
-// --- Mutation operations ---
-
-// Create creates a new playlist (when name is provided) or replaces tracks on an existing
-// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
-func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
- usr, _ := request.UserFrom(ctx)
- err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
- var pls *model.Playlist
- var err error
-
- if playlistId != "" {
- pls, err = tx.Playlist().Get(ctx, 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 {
- return model.ErrNotAuthorized
- }
- if !pls.TracksEditable() {
- return model.ErrPlaylistNotEditable
- }
- } else {
- pls = &model.Playlist{Name: name}
- pls.OwnerID = usr.ID
- }
- pls.Tracks = nil
- pls.AddMediaFilesByID(ids)
-
- err = tx.Playlist().Put(ctx, pls)
- playlistId = pls.ID
- return err
- })
- return playlistId, err
-}
-
-func (s *playlists) Delete(ctx context.Context, id string) error {
- pls, err := s.checkWritable(ctx, id)
- if err != nil {
- return err
- }
-
- // Clean up custom cover image file if one exists
- if path := pls.UploadedImagePath(); path != "" {
- if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
- log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err)
- }
- }
-
- return s.ds.Playlist().Delete(ctx, id)
-}
-
-func (s *playlists) Update(ctx context.Context, playlistID string,
- name *string, comment *string, public *bool,
- idsToAdd []string, idxToRemove []int) error {
- var pls *model.Playlist
- var err error
- hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
- if hasTrackChanges {
- pls, err = s.checkTracksEditable(ctx, playlistID)
- } else {
- pls, err = s.checkWritable(ctx, playlistID)
- }
- if err != nil {
- return err
- }
- return s.ds.WithTxImmediate(func(tx model.DataStore) error {
- repo := tx.Playlist()
-
- if len(idxToRemove) > 0 {
- tracksRepo := repo.Tracks(ctx, 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 {
- return err
- }
- if len(idsToAdd) > 0 {
- if _, err := tracksRepo.Add(ctx, idsToAdd); err != nil {
- return err
- }
- }
- return s.updateMetadata(ctx, tx, pls, name, comment, public)
- }
-
- if len(idsToAdd) > 0 {
- if _, err := repo.Tracks(ctx, playlistID, false).Add(ctx, idsToAdd); err != nil {
- return err
- }
- }
- if name == nil && comment == nil && public == nil {
- return nil
- }
- // Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
- return s.updateMetadata(ctx, tx, pls, name, comment, public)
- })
-}
-
-// --- Permission helpers ---
-
-// 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)
- if err != nil {
- return nil, err
- }
- usr, _ := request.UserFrom(ctx)
- if !usr.IsAdmin && pls.OwnerID != usr.ID {
- return nil, model.ErrNotAuthorized
- }
- return pls, nil
-}
-
-// checkTracksEditable verifies the user owns the playlist and its tracks are editable.
-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
- }
- return pls, nil
-}
-
-// updateMetadata applies optional metadata changes to a playlist and persists it.
-// Accepts a DataStore parameter so it can be used inside transactions.
-// The caller is responsible for permission checks.
-func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
- if name != nil {
- pls.Name = *name
- }
- if comment != nil {
- pls.Comment = *comment
- }
- if public != nil {
- pls.Public = *public
- }
- return ds.Playlist().Put(ctx, pls)
-}
-
-// --- Track management operations ---
-
-func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
- 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
-}
-
-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)
-}
-
-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)
-}
-
-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)
-}
-
-func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
- if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
- return err
- }
- return s.ds.WithTx(func(tx model.DataStore) error {
- return tx.Playlist().Tracks(ctx, playlistID, false).Delete(ctx, trackIds...)
- })
-}
-
-func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
- 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)
- })
-}
-
-// --- Cover art operations ---
-
-func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error {
- pls, err := s.checkWritable(ctx, playlistID)
- if err != nil {
- return err
- }
-
- oldPath := pls.UploadedImagePath()
- filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext)
- if err != nil {
- return err
- }
-
- 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
-}
-
-func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
- pls, err := s.checkWritable(ctx, playlistID)
- if err != nil {
- return err
- }
-
- if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil {
- return err
- }
-
- pls.UploadedImage = ""
- if err := s.ds.Playlist().Put(ctx, pls); err != nil {
- return err
- }
- s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID)
- return nil
-}
diff --git a/core/playlists/playlists_suite_test.go b/core/playlists/playlists_suite_test.go
deleted file mode 100644
index b57248490..000000000
--- a/core/playlists/playlists_suite_test.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package playlists_test
-
-import (
- "testing"
-
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-func TestPlaylists(t *testing.T) {
- tests.Init(t, false)
- log.SetLevel(log.LevelFatal)
- RegisterFailHandler(Fail)
- RunSpecs(t, "Playlists Suite")
-}
diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go
deleted file mode 100644
index ec44de3bc..000000000
--- a/core/playlists/playlists_test.go
+++ /dev/null
@@ -1,524 +0,0 @@
-package playlists_test
-
-import (
- "context"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
- "github.com/navidrome/navidrome/core/artwork"
- "github.com/navidrome/navidrome/core/playlists"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/criteria"
- "github.com/navidrome/navidrome/model/request"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("Playlists", func() {
- var ds *tests.MockDataStore
- var ps playlists.Playlists
- var mockPlsRepo *tests.MockPlaylistRepo
- ctx := context.Background()
-
- BeforeEach(func() {
- mockPlsRepo = tests.CreateMockPlaylistRepo()
- ds = &tests.MockDataStore{
- MockedPlaylist: mockPlsRepo,
- MockedLibrary: &tests.MockLibraryRepo{},
- }
- ctx = request.WithUser(ctx, model.User{ID: "123"})
- })
-
- Describe("Delete", func() {
- var mockTracks *tests.MockPlaylistTrackRepo
-
- BeforeEach(func() {
- mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
- 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("allows owner to delete their playlist", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- err := ps.Delete(ctx, "pls-1")
- Expect(err).ToNot(HaveOccurred())
- Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
- })
-
- It("allows admin to delete any playlist", func() {
- ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
- err := ps.Delete(ctx, "pls-1")
- Expect(err).ToNot(HaveOccurred())
- Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
- })
-
- It("denies non-owner, non-admin from deleting", func() {
- ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
- err := ps.Delete(ctx, "pls-1")
- Expect(err).To(MatchError(model.ErrNotAuthorized))
- Expect(mockPlsRepo.Deleted).To(BeEmpty())
- })
-
- It("returns error when playlist not found", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- err := ps.Delete(ctx, "nonexistent")
- Expect(err).To(Equal(model.ErrNotFound))
- })
- })
-
- 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{
- "pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
- "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))
- })
-
- It("creates a new playlist with owner set from context", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
- Expect(err).ToNot(HaveOccurred())
- Expect(id).ToNot(BeEmpty())
- Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
- Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
- })
-
- It("replaces tracks on existing playlist when owner matches", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
- Expect(err).ToNot(HaveOccurred())
- Expect(id).To(Equal("pls-1"))
- Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
- })
-
- It("allows admin to replace tracks on any playlist", func() {
- ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
- id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
- Expect(err).ToNot(HaveOccurred())
- Expect(id).To(Equal("pls-2"))
- })
-
- It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- _, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
- Expect(err).To(MatchError(model.ErrNotAuthorized))
- })
-
- It("returns error when existing playlistId not found", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- _, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
- Expect(err).To(Equal(model.ErrNotFound))
- })
-
- 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))
- })
- })
-
- Describe("Update", 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-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))
- })
-
- 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)
- 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)
- 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)
- 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)
- 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))
- })
-
- 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))
- })
-
- 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)
- Expect(err).ToNot(HaveOccurred())
- })
- })
-
- Describe("AddTracks", 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"}}},
- "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
- "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true},
- }
- mockPlsRepo.TracksRepo = mockTracks
- ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
- })
-
- It("allows owner to add tracks", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
- Expect(err).ToNot(HaveOccurred())
- Expect(count).To(Equal(2))
- Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
- })
-
- It("allows admin to add tracks to any playlist", func() {
- ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
- count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
- Expect(err).ToNot(HaveOccurred())
- Expect(count).To(Equal(2))
- })
-
- It("denies non-owner, non-admin", func() {
- ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
- _, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
- Expect(err).To(MatchError(model.ErrNotAuthorized))
- })
-
- 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))
- })
-
- It("returns error when playlist not found", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- _, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
- Expect(err).To(Equal(model.ErrNotFound))
- })
- })
-
- Describe("RemoveTracks", 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"},
- "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("allows owner to remove tracks", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
- Expect(err).ToNot(HaveOccurred())
- Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
- })
-
- 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))
- })
-
- It("denies non-owner", func() {
- ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
- err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
- Expect(err).To(MatchError(model.ErrNotAuthorized))
- })
- })
-
- 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
-
- BeforeEach(func() {
- mockTracks = &tests.MockPlaylistTrackRepo{}
- 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("allows owner to reorder", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
- Expect(err).ToNot(HaveOccurred())
- Expect(mockTracks.Reordered).To(BeTrue())
- })
-
- 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))
- })
- })
-
- Describe("SetImage", func() {
- var tmpDir string
-
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- tmpDir = GinkgoT().TempDir()
- conf.Server.DataFolder = conf.NewDir(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))
- })
-
- It("saves image file and updates UploadedImage", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- reader := strings.NewReader("fake image data")
- err := ps.SetImage(ctx, "pls-1", reader, ".jpg")
- Expect(err).ToNot(HaveOccurred())
-
- Expect(mockPlsRepo.Last.UploadedImage).To(Equal("pls-1_my_playlist.jpg"))
- absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
- data, err := os.ReadFile(absPath)
- Expect(err).ToNot(HaveOccurred())
- Expect(string(data)).To(Equal("fake image data"))
- })
-
- It("removes old image when replacing", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
-
- // Upload first image
- err := ps.SetImage(ctx, "pls-1", strings.NewReader("first"), ".png")
- Expect(err).ToNot(HaveOccurred())
- oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.png")
- Expect(oldPath).To(BeAnExistingFile())
-
- // Upload replacement image
- err = ps.SetImage(ctx, "pls-1", strings.NewReader("second"), ".jpg")
- Expect(err).ToNot(HaveOccurred())
- Expect(oldPath).ToNot(BeAnExistingFile())
- newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
- Expect(newPath).To(BeAnExistingFile())
- })
-
- It("allows admin to set image on any playlist", func() {
- ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
- err := ps.SetImage(ctx, "pls-other", strings.NewReader("data"), ".jpg")
- Expect(err).ToNot(HaveOccurred())
- })
-
- It("denies non-owner", func() {
- ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
- err := ps.SetImage(ctx, "pls-1", strings.NewReader("data"), ".jpg")
- 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.SetImage(ctx, "nonexistent", strings.NewReader("data"), ".jpg")
- Expect(err).To(Equal(model.ErrNotFound))
- })
- })
-
- Describe("RemoveImage", func() {
- var tmpDir string
-
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- tmpDir = GinkgoT().TempDir()
- conf.Server.DataFolder = conf.NewDir(tmpDir)
-
- // Create a real image file on disk
- imgDir := filepath.Join(tmpDir, "artwork", "playlist")
- Expect(os.MkdirAll(imgDir, 0755)).To(Succeed())
- Expect(os.WriteFile(filepath.Join(imgDir, "pls-1.jpg"), []byte("img data"), 0600)).To(Succeed())
-
- mockPlsRepo.Data = map[string]*model.Playlist{
- "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", UploadedImage: "pls-1.jpg"},
- "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))
- })
-
- It("removes file and clears UploadedImage", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- err := ps.RemoveImage(ctx, "pls-1")
- Expect(err).ToNot(HaveOccurred())
-
- Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
- absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg")
- Expect(absPath).ToNot(BeAnExistingFile())
- })
-
- It("succeeds even if playlist has no image", func() {
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- err := ps.RemoveImage(ctx, "pls-empty")
- Expect(err).ToNot(HaveOccurred())
- 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")
- 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.RemoveImage(ctx, "nonexistent")
- Expect(err).To(Equal(model.ErrNotFound))
- })
- })
-})
diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go
deleted file mode 100644
index f8e8a9d41..000000000
--- a/core/playlists/rest_adapter.go
+++ /dev/null
@@ -1,187 +0,0 @@
-package playlists
-
-import (
- "context"
- "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
-}
-
-// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter,
-// delegating to service methods for all mutations.
-type playlistRepositoryWrapper struct {
- model.PlaylistRepository
- 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) Update(ctx context.Context, id string, entity model.Playlist, cols ...string) error {
- return r.service.updatePlaylistEntity(ctx, id, &entity, cols...)
-}
-
-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
- }
- }
- 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)
- if tracks == nil {
- return nil
- }
- return tracks
-}
-
-// savePlaylist creates a new playlist, assigning the owner from context.
-// Only Name, Comment, Public, and Rules are user-settable via the REST API.
-func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
- usr, _ := request.UserFrom(ctx)
- pls.OwnerID = usr.ID
- pls.ID = "" // Force new creation
- pls.Path = "" // Server-managed (M3U file path)
- pls.Sync = false // Server-managed (M3U sync flag)
- 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)
- if err != nil {
- return "", err
- }
- return pls.ID, nil
-}
-
-// 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 {
- current, err := s.checkWritable(ctx, id)
- if err != nil {
- return err
- }
-
- sent := sentFields(cols)
-
- usr, _ := request.UserFrom(ctx)
- ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID
- if !usr.IsAdmin && ownerChanged {
- 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)
-
- if nameChanged || commentChanged || ownerChanged || rulesChanged {
- return s.applyContentUpdate(ctx, current, entity, sent,
- nameChanged, commentChanged, ownerChanged, rulesChanged)
- }
- return s.applyFlagsOnly(ctx, current, entity, sent)
-}
-
-// applyContentUpdate handles updates that change at least one of name/comment/
-// owner/rules. It goes through updateMetadata, which always bumps updatedAt
-// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the
-// field is absent from the request OR present-but-unchanged (so updateMetadata
-// skips them); publicPtr is nil only when public is absent from the request
-// (an idempotent public value is still forwarded).
-func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist,
- sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool,
-) error {
- if ownerChanged {
- current.OwnerID = entity.OwnerID
- }
- if rulesChanged {
- current.Rules = entity.Rules
- 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 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 {
- current.Sync = entity.Sync
- updateCols = append(updateCols, "sync")
- }
- if sent("public") && current.Public != entity.Public {
- current.Public = entity.Public
- updateCols = append(updateCols, "public")
- }
- if len(updateCols) == 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
- }
-}
-
-func rulesEqual(a, b *criteria.Criteria) bool {
- if a == b {
- return true
- }
- if a == nil || b == nil {
- return false
- }
- return reflect.DeepEqual(a, b)
-}
diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go
deleted file mode 100644
index e82dd9df5..000000000
--- a/core/playlists/rest_adapter_test.go
+++ /dev/null
@@ -1,460 +0,0 @@
-package playlists_test
-
-import (
- "context"
- "time"
-
- "github.com/deluan/rest"
- "github.com/navidrome/navidrome/core/artwork"
- "github.com/navidrome/navidrome/core/playlists"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/criteria"
- "github.com/navidrome/navidrome/model/request"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("REST Adapter", func() {
- var ds *tests.MockDataStore
- var ps playlists.Playlists
- var mockPlsRepo *tests.MockPlaylistRepo
- ctx := context.Background()
-
- BeforeEach(func() {
- mockPlsRepo = tests.CreateMockPlaylistRepo()
- ds = &tests.MockDataStore{
- MockedPlaylist: mockPlsRepo,
- MockedLibrary: &tests.MockLibraryRepo{},
- }
- ctx = request.WithUser(ctx, model.User{ID: "123"})
- })
-
- Describe("NewRepository", func() {
- var repo rest.Persistable[model.Playlist]
-
- 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))
- })
-
- 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])
- pls := &model.Playlist{Name: "New Playlist"}
- id, err := repo.Save(ctx, pls)
- Expect(err).ToNot(HaveOccurred())
- Expect(id).ToNot(BeEmpty())
- Expect(pls.OwnerID).To(Equal("user-1"))
- })
-
- 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])
- pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
- _, err := repo.Save(ctx, 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])
- pls := &model.Playlist{
- Name: "Legit Playlist",
- Comment: "A comment",
- Public: true,
- Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}},
- Path: "/some/path/playlist.m3u",
- Sync: true,
- UploadedImage: "injected-image-path",
- ExternalImageURL: "http://evil.example.com/ssrf",
- EvaluatedAt: new(time.Now()),
- }
- _, err := repo.Save(ctx, pls)
- Expect(err).ToNot(HaveOccurred())
-
- saved := mockPlsRepo.Last
- // User-settable fields are preserved
- Expect(saved.Name).To(Equal("Legit Playlist"))
- Expect(saved.Comment).To(Equal("A comment"))
- Expect(saved.Public).To(BeTrue())
- Expect(saved.Rules).ToNot(BeNil())
- // Server-managed fields are cleared
- Expect(saved.Path).To(BeEmpty())
- Expect(saved.Sync).To(BeFalse())
- Expect(saved.UploadedImage).To(BeEmpty())
- Expect(saved.ExternalImageURL).To(BeEmpty())
- Expect(saved.EvaluatedAt).To(BeNil())
- })
- })
-
- 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])
- pls := &model.Playlist{Name: "Updated"}
- err := repo.Update(ctx, "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])
- pls := &model.Playlist{Name: "Updated"}
- err := repo.Update(ctx, "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])
- pls := &model.Playlist{Name: "Updated"}
- err := repo.Update(ctx, "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])
- pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
- err := repo.Update(ctx, "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",
- Name: "Smart Playlist",
- OwnerID: "user-1",
- 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{Name: "Smart Playlist", Rules: newRules}
- err := repo.Update(ctx, "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{
- ID: "file-pls",
- Name: "File Playlist",
- OwnerID: "user-1",
- Path: "/music/playlist.m3u",
- Sync: true,
- UpdatedAt: originalTime,
- }
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- repo = ps.Repository().(rest.Persistable[model.Playlist])
- pls := &model.Playlist{Name: "File Playlist", Sync: false}
- err := repo.Update(ctx, "file-pls", *pls)
- Expect(err).ToNot(HaveOccurred())
- Expect(mockPlsRepo.Last.Sync).To(BeFalse())
- Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime))
- })
-
- It("does not allow setting sync on non-file-backed playlists", func() {
- mockPlsRepo.Data["manual-pls"] = &model.Playlist{
- ID: "manual-pls",
- Name: "Manual Playlist",
- OwnerID: "user-1",
- Path: "",
- Sync: false,
- }
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- repo = ps.Repository().(rest.Persistable[model.Playlist])
- pls := &model.Playlist{Name: "Manual Playlist", Sync: true}
- err := repo.Update(ctx, "manual-pls", *pls)
- Expect(err).ToNot(HaveOccurred())
- Expect(mockPlsRepo.Last).To(BeNil())
- })
-
- It("does not bump updatedAt when only public changes", func() {
- originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
- mockPlsRepo.Data["pls-pub"] = &model.Playlist{
- ID: "pls-pub",
- Name: "My Playlist",
- OwnerID: "user-1",
- Public: false,
- UpdatedAt: originalTime,
- }
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- repo = ps.Repository().(rest.Persistable[model.Playlist])
- pls := &model.Playlist{Name: "My Playlist", Public: true}
- err := repo.Update(ctx, "pls-pub", *pls)
- Expect(err).ToNot(HaveOccurred())
- Expect(mockPlsRepo.Last.Public).To(BeTrue())
- Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime))
- })
-
- It("bumps updatedAt when name changes along with sync", func() {
- mockPlsRepo.Data["file-pls2"] = &model.Playlist{
- ID: "file-pls2",
- Name: "Old Name",
- OwnerID: "user-1",
- Path: "/music/playlist.m3u",
- Sync: true,
- }
- ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
- repo = ps.Repository().(rest.Persistable[model.Playlist])
- pls := &model.Playlist{Name: "New Name", Sync: false}
- err := repo.Update(ctx, "file-pls2", *pls)
- Expect(err).ToNot(HaveOccurred())
- Expect(mockPlsRepo.Last.Name).To(Equal("New Name"))
- Expect(mockPlsRepo.Last.Sync).To(BeFalse())
- })
-
- 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])
- pls := &model.Playlist{Name: "Updated"}
- err := repo.Update(ctx, "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")
- 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")
- Expect(err).To(Equal(rest.ErrPermissionDenied))
- })
- })
- })
-})
diff --git a/core/playlists_test.go b/core/playlists_test.go
new file mode 100644
index 000000000..fb42f9c9f
--- /dev/null
+++ b/core/playlists_test.go
@@ -0,0 +1,350 @@
+package core
+
+import (
+ "context"
+ "os"
+ "strconv"
+ "strings"
+ "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/model/request"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "golang.org/x/text/unicode/norm"
+)
+
+var _ = Describe("Playlists", func() {
+ var ds *tests.MockDataStore
+ var ps Playlists
+ var mockPlsRepo mockedPlaylistRepo
+ var mockLibRepo *tests.MockLibraryRepo
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ mockPlsRepo = mockedPlaylistRepo{}
+ mockLibRepo = &tests.MockLibraryRepo{}
+ ds = &tests.MockDataStore{
+ MockedPlaylist: &mockPlsRepo,
+ MockedLibrary: mockLibRepo,
+ }
+ ctx = request.WithUser(ctx, model.User{ID: "123"})
+ // Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
+ })
+
+ Describe("ImportFile", func() {
+ var folder *model.Folder
+ BeforeEach(func() {
+ ps = NewPlaylists(ds)
+ ds.MockedMediaFile = &mockedMediaFileRepo{}
+ libPath, _ := os.Getwd()
+ folder = &model.Folder{
+ ID: "1",
+ LibraryID: 1,
+ LibraryPath: libPath,
+ Path: "tests/fixtures",
+ Name: "playlists",
+ }
+ })
+
+ Describe("M3U", func() {
+ It("parses well-formed playlists", func() {
+ pls, err := ps.ImportFile(ctx, folder, "pls1.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Tracks).To(HaveLen(2))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
+ Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
+ Expect(mockPlsRepo.last).To(Equal(pls))
+ })
+
+ It("parses playlists using LF ending", func() {
+ pls, err := ps.ImportFile(ctx, folder, "lf-ended.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ })
+
+ It("parses playlists using CR ending (old Mac format)", func() {
+ pls, err := ps.ImportFile(ctx, folder, "cr-ended.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ })
+
+ It("parses playlists with UTF-8 BOM marker", func() {
+ pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("Test Playlist"))
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
+ })
+
+ It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() {
+ pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("UTF-16 Test Playlist"))
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
+ })
+ })
+
+ Describe("NSP", func() {
+ It("parses well-formed playlists", func() {
+ pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockPlsRepo.last).To(Equal(pls))
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("Recently Played"))
+ Expect(pls.Comment).To(Equal("Recently played tracks"))
+ Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
+ Expect(pls.Rules.Order).To(Equal("desc"))
+ Expect(pls.Rules.Limit).To(Equal(100))
+ Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
+ })
+ It("returns an error if the playlist is not well-formed", func() {
+ _, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
+ Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
+ })
+ })
+ })
+
+ Describe("ImportM3U", func() {
+ var repo *mockedMediaFileFromListRepo
+ BeforeEach(func() {
+ repo = &mockedMediaFileFromListRepo{}
+ ds.MockedMediaFile = repo
+ ps = NewPlaylists(ds)
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
+ ctx = request.WithUser(ctx, model.User{ID: "123"})
+ })
+
+ It("parses well-formed playlists", func() {
+ repo.data = []string{
+ "tests/test.mp3",
+ "tests/test.ogg",
+ "tests/01 Invisible (RED) Edit Version.mp3",
+ "downloads/newfile.flac",
+ }
+ m3u := strings.Join([]string{
+ "#PLAYLIST:playlist 1",
+ "/music/tests/test.mp3",
+ "/music/tests/test.ogg",
+ "/new/downloads/newfile.flac",
+ "file:///music/tests/01%20Invisible%20(RED)%20Edit%20Version.mp3",
+ }, "\n")
+ f := strings.NewReader(m3u)
+
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("playlist 1"))
+ Expect(pls.Sync).To(BeFalse())
+ Expect(pls.Tracks).To(HaveLen(4))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/test.mp3"))
+ Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
+ Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
+ Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
+ Expect(mockPlsRepo.last).To(Equal(pls))
+ })
+
+ It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
+ repo.data = []string{
+ "tests/test.mp3",
+ "tests/test.ogg",
+ "/tests/01 Invisible (RED) Edit Version.mp3",
+ }
+ m3u := strings.Join([]string{
+ "/music/tests/test.mp3",
+ "/music/tests/test.ogg",
+ }, "\n")
+ f := strings.NewReader(m3u)
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ _, err = time.Parse(time.RFC3339, pls.Name)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(2))
+ })
+
+ It("returns only tracks that exist in the database and in the same other as the m3u", func() {
+ repo.data = []string{
+ "album1/test1.mp3",
+ "album2/test2.mp3",
+ "album3/test3.mp3",
+ }
+ m3u := strings.Join([]string{
+ "/music/album3/test3.mp3",
+ "/music/album1/test1.mp3",
+ "/music/album4/test4.mp3",
+ "/music/album2/test2.mp3",
+ }, "\n")
+ f := strings.NewReader(m3u)
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(3))
+ Expect(pls.Tracks[0].Path).To(Equal("album3/test3.mp3"))
+ Expect(pls.Tracks[1].Path).To(Equal("album1/test1.mp3"))
+ Expect(pls.Tracks[2].Path).To(Equal("album2/test2.mp3"))
+ })
+
+ It("is case-insensitive when comparing paths", func() {
+ repo.data = []string{
+ "abc/tEsT1.Mp3",
+ }
+ m3u := strings.Join([]string{
+ "/music/ABC/TeSt1.mP3",
+ }, "\n")
+ f := strings.NewReader(m3u)
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
+ })
+
+ It("handles Unicode normalization when comparing paths", func() {
+ // Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD
+ // The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent)
+
+ const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a"
+
+ // Simulate a database entry with NFD encoding (as stored by macOS filesystem)
+ nfdPath := norm.NFD.String(pathWithAccents)
+ repo.data = []string{nfdPath}
+
+ // Simulate an Apple Music M3U playlist entry with NFC encoding
+ nfcPath := norm.NFC.String("/music/" + pathWithAccents)
+ m3u := strings.Join([]string{
+ nfcPath,
+ }, "\n")
+ f := strings.NewReader(m3u)
+
+ pls, err := ps.ImportM3U(ctx, f)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences")
+ Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
+ })
+ })
+
+ Describe("normalizePathForComparison", func() {
+ It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
+ // Test with NFD (decomposed) input - as would come from macOS filesystem
+ nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
+ normalized := normalizePathForComparison(nfdPath)
+ Expect(normalized).To(Equal("michèle"))
+
+ // Test with NFC (composed) input - as would come from Apple Music M3U
+ nfcPath := "Michèle" // This might be in NFC form
+ normalizedNfc := normalizePathForComparison(nfcPath)
+
+ // Ensure the two paths are not equal in their original forms
+ Expect(nfdPath).ToNot(Equal(nfcPath))
+
+ // Both should normalize to the same result
+ Expect(normalized).To(Equal(normalizedNfc))
+ })
+
+ It("handles paths with mixed case and Unicode characters", func() {
+ path := "Artist/Noël Coward/Album/Song.mp3"
+ normalized := normalizePathForComparison(path)
+ Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
+ })
+ })
+
+ Describe("InPlaylistsPath", func() {
+ var folder model.Folder
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ folder = model.Folder{
+ LibraryPath: "/music",
+ Path: "playlists/abc",
+ Name: "folder1",
+ }
+ })
+
+ It("returns true if PlaylistsPath is empty", func() {
+ conf.Server.PlaylistsPath = ""
+ Expect(InPlaylistsPath(folder)).To(BeTrue())
+ })
+
+ It("returns true if PlaylistsPath is any (**/**)", func() {
+ conf.Server.PlaylistsPath = "**/**"
+ Expect(InPlaylistsPath(folder)).To(BeTrue())
+ })
+
+ It("returns true if folder is in PlaylistsPath", func() {
+ conf.Server.PlaylistsPath = "other/**:playlists/**"
+ Expect(InPlaylistsPath(folder)).To(BeTrue())
+ })
+
+ It("returns false if folder is not in PlaylistsPath", func() {
+ conf.Server.PlaylistsPath = "other"
+ Expect(InPlaylistsPath(folder)).To(BeFalse())
+ })
+
+ It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
+ conf.Server.PlaylistsPath = "."
+ Expect(InPlaylistsPath(folder)).To(BeFalse())
+
+ folder2 := model.Folder{
+ LibraryPath: "/music",
+ Path: "",
+ Name: ".",
+ }
+
+ Expect(InPlaylistsPath(folder2)).To(BeTrue())
+ })
+ })
+})
+
+// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
+type mockedMediaFileRepo struct {
+ model.MediaFileRepository
+}
+
+func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
+ var mfs model.MediaFiles
+ for idx, path := range paths {
+ mfs = append(mfs, model.MediaFile{
+ ID: strconv.Itoa(idx),
+ Path: path,
+ })
+ }
+ return mfs, nil
+}
+
+// mockedMediaFileFromListRepo's FindByPaths method returns a list of MediaFiles based on the data field
+type mockedMediaFileFromListRepo struct {
+ model.MediaFileRepository
+ data []string
+}
+
+func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
+ var mfs model.MediaFiles
+ for idx, path := range r.data {
+ mfs = append(mfs, model.MediaFile{
+ ID: strconv.Itoa(idx),
+ Path: path,
+ })
+ }
+ return mfs, nil
+}
+
+type mockedPlaylistRepo struct {
+ last *model.Playlist
+ model.PlaylistRepository
+}
+
+func (r *mockedPlaylistRepo) FindByPath(string) (*model.Playlist, error) {
+ return nil, model.ErrNotFound
+}
+
+func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
+ r.last = pls
+ return nil
+}
diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go
deleted file mode 100644
index 345606324..000000000
--- a/core/publicurl/publicurl.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package publicurl
-
-import (
- "cmp"
- "context"
- "net/url"
- "path"
- "strconv"
- "strings"
-
- "github.com/navidrome/navidrome/conf"
- "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"
- "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 {
- 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)
-}
-
-// 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 {
- if conf.Server.ShareURL == "" {
- return AbsoluteURL(ctx, u, params)
- }
- shareUrl, err := url.Parse(conf.Server.ShareURL)
- if err != nil {
- return AbsoluteURL(ctx, u, params)
- }
- buildUrl, err := url.Parse(u)
- if err != nil {
- return AbsoluteURL(ctx, u, params)
- }
- buildUrl.Scheme = shareUrl.Scheme
- buildUrl.Host = shareUrl.Host
- if basePath := strings.TrimRight(shareUrl.Path, "/"); basePath != "" {
- buildUrl.Path = path.Join(basePath, buildUrl.Path)
- }
- if len(params) > 0 {
- buildUrl.RawQuery = params.Encode()
- }
- return buildUrl.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 {
- buildUrl, err := url.Parse(u)
- if err != nil {
- log.Error(ctx, "Failed to parse URL path", "url", u, err)
- return ""
- }
- if strings.HasPrefix(u, "/") {
- buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path)
- 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 {
- 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)
- }
- }
- if len(params) > 0 {
- buildUrl.RawQuery = params.Encode()
- }
- return buildUrl.String()
-}
diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go
deleted file mode 100644
index 0f8ea806c..000000000
--- a/core/publicurl/publicurl_test.go
+++ /dev/null
@@ -1,232 +0,0 @@
-package publicurl_test
-
-import (
- "context"
- "net/url"
- "testing"
-
- "github.com/go-chi/jwtauth/v5"
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
- "github.com/navidrome/navidrome/core/auth"
- "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"
-)
-
-func TestPublicURL(t *testing.T) {
- tests.Init(t, false)
- log.SetLevel(log.LevelFatal)
- RegisterFailHandler(Fail)
- RunSpecs(t, "Public URL Suite")
-}
-
-var _ = Describe("Public URL Utilities", func() {
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- })
-
- Describe("PublicURL", func() {
- When("ShareURL is set", func() {
- BeforeEach(func() {
- conf.Server.ShareURL = "https://share.example.com"
- })
-
- It("uses ShareURL as the base", func() {
- result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil)
- Expect(result).To(Equal("https://share.example.com/path/to/resource"))
- })
-
- It("includes query parameters", func() {
- params := url.Values{"size": []string{"300"}, "format": []string{"png"}}
- result := publicurl.PublicURL(context.Background(), "/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"))
- })
- })
-
- When("ShareURL includes a path", func() {
- BeforeEach(func() {
- conf.Server.ShareURL = "https://example.com/navi"
- })
-
- It("prepends the ShareURL path to the resource", func() {
- result := publicurl.PublicURL(context.Background(), "/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() {
- params := url.Values{"size": []string{"600"}}
- result := publicurl.PublicURL(context.Background(), "/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)
- Expect(result).To(Equal("https://example.com/navi/share/img/hash"))
- })
- })
-
- When("ShareURL is not set", func() {
- BeforeEach(func() {
- conf.Server.ShareURL = ""
- })
-
- It("falls back to AbsoluteURL with request", func() {
- ctx := request.WithServerAddress(context.Background(), "https", "myserver.com")
- result := publicurl.PublicURL(ctx, "/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"))
- })
- })
- })
-
- Describe("AbsoluteURL", func() {
- When("BaseHost is set", func() {
- BeforeEach(func() {
- conf.Server.BaseHost = "configured.example.com"
- conf.Server.BaseScheme = "https"
- conf.Server.BasePath = ""
- })
-
- It("uses BaseHost and BaseScheme", func() {
- result := publicurl.AbsoluteURL(context.Background(), "/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)
- Expect(result).To(Equal("http://configured.example.com/path/to/resource"))
- })
- })
-
- When("BaseHost is not set", func() {
- BeforeEach(func() {
- conf.Server.BaseHost = ""
- conf.Server.BasePath = ""
- })
-
- It("extracts host from request", func() {
- ctx := request.WithServerAddress(context.Background(), "https", "request.example.com")
- result := publicurl.AbsoluteURL(ctx, "/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"))
- })
- })
-
- When("BasePath is set", func() {
- BeforeEach(func() {
- conf.Server.BasePath = "/navidrome"
- conf.Server.BaseHost = "example.com"
- conf.Server.BaseScheme = "https"
- })
-
- It("prepends BasePath to the URL", func() {
- result := publicurl.AbsoluteURL(context.Background(), "/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)
- Expect(result).To(Equal("https://other.example.com/path"))
- })
-
- It("includes query parameters", func() {
- conf.Server.BaseHost = "example.com"
- conf.Server.BaseScheme = "https"
- params := url.Values{"key": []string{"value"}}
- result := publicurl.AbsoluteURL(context.Background(), "/path", params)
- Expect(result).To(Equal("https://example.com/path?key=value"))
- })
- })
-
- Describe("ImageURL", 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)
- })
-
- It("generates a URL with the artwork token", func() {
- artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil)
- result := publicurl.ImageURL(context.Background(), 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)
- 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)
- 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..4f64a3c2b 100644
--- a/core/scrobbler/buffered_scrobbler.go
+++ b/core/scrobbler/buffered_scrobbler.go
@@ -5,57 +5,15 @@ 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.
-type Loader func() (Scrobbler, bool)
-
-// newBufferedScrobbler creates a buffered scrobbler that wraps a static scrobbler instance.
-// Use this for builtin scrobblers that don't change.
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
- return newBufferedScrobblerWithLoader(ds, service, func() (Scrobbler, bool) {
- return s, true
- })
-}
-
-// newBufferedScrobblerWithLoader creates a buffered scrobbler that dynamically loads
-// the underlying scrobbler on each call. Use this for plugin scrobblers that may be
-// reloaded (e.g., after configuration changes).
-func newBufferedScrobblerWithLoader(ds model.DataStore, service string, loader Loader) *bufferedScrobbler {
ctx, cancel := context.WithCancel(context.Background())
b := &bufferedScrobbler{
ds: ds,
- loader: loader,
+ wrapped: s,
service: service,
wakeSignal: make(chan struct{}, 1),
ctx: ctx,
@@ -67,10 +25,10 @@ func newBufferedScrobblerWithLoader(ds model.DataStore, service string, loader L
type bufferedScrobbler struct {
ds model.DataStore
- loader Loader
+ wrapped Scrobbler
service string
wakeSignal chan struct{}
- ctx context.Context //nolint:containedctx // scrobbler lifecycle ctx, cancelled by Stop
+ ctx context.Context
cancel context.CancelFunc
}
@@ -81,23 +39,15 @@ func (b *bufferedScrobbler) Stop() {
}
func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
- s, ok := b.loader()
- if !ok {
- return false
- }
- return s.IsAuthorized(ctx, userId)
+ return b.wrapped.IsAuthorized(ctx, userId)
}
func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
- s, ok := b.loader()
- if !ok {
- return errors.New("scrobbler not available")
- }
- return s.NowPlaying(ctx, userId, track, position)
+ return b.wrapped.NowPlaying(ctx, userId, track, position)
}
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
}
@@ -106,14 +56,6 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob
return nil
}
-func (b *bufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
- s, ok := b.loader()
- if !ok {
- return errors.New("scrobbler not available")
- }
- return s.PlaybackReport(ctx, info)
-}
-
func (b *bufferedScrobbler) sendWakeSignal() {
// Don't block if the previous signal was not read yet
select {
@@ -123,98 +65,67 @@ 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
- }
- s, ok := b.loader()
- if !ok {
- log.Warn(ctx, "Scrobbler not available, will retry later", "scrobbler", b.service)
- return false, 0
+ return true
}
log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist)
- err = s.Scrobble(ctx, entry.UserID, Scrobble{
+ err = b.wrapped.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..c1440046d 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")
@@ -46,23 +38,22 @@ var _ = Describe("BufferedScrobbler", func() {
It("forwards NowPlaying calls", func() {
track := &model.MediaFile{ID: "123", Title: "Test Track"}
Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed())
- Expect(scr.GetNowPlayingCalled()).To(BeTrue())
- Expect(scr.GetUserID()).To(Equal("user1"))
- Expect(scr.GetTrack()).To(Equal(track))
+ Expect(scr.NowPlayingCalled).To(BeTrue())
+ Expect(scr.UserID).To(Equal("user1"))
+ Expect(scr.Track).To(Equal(track))
})
It("enqueues scrobbles to buffer", 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())
+ Expect(buffer.Length()).To(Equal(int64(1)))
- // Wait for the background goroutine to process the scrobble.
- // We don't check buffer.Length() here because the background goroutine
- // may dequeue the entry before we can observe it.
+ // Wait for the scrobble to be sent
Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
lastScrobble := scr.LastScrobble.Load()
@@ -70,16 +61,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 +86,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..f8567e91b 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")
)
@@ -25,7 +23,6 @@ type Scrobbler interface {
IsAuthorized(ctx context.Context, userId string) bool
NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error
Scrobble(ctx context.Context, userId string, s Scrobble) error
- PlaybackReport(ctx context.Context, info PlaybackSession) error
}
type Constructor func(ds model.DataStore) Scrobbler
diff --git a/core/scrobbler/nowplaying_worker.go b/core/scrobbler/nowplaying_worker.go
deleted file mode 100644
index 1bacec689..000000000
--- a/core/scrobbler/nowplaying_worker.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package scrobbler
-
-import (
- "context"
- "time"
-
- "github.com/navidrome/navidrome/consts"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
-)
-
-func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
- p.npMu.Lock()
- defer p.npMu.Unlock()
- ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
- p.npQueue[playerId] = nowPlayingEntry{
- ctx: ctx,
- userId: userId,
- track: track,
- position: position,
- }
- p.sendNowPlayingSignal()
-}
-
-func (p *playTracker) sendNowPlayingSignal() {
- // Don't block if the previous signal was not read yet
- select {
- case p.npSignal <- struct{}{}:
- default:
- }
-}
-
-func (p *playTracker) nowPlayingWorker() {
- defer close(p.workerDone)
- for {
- select {
- case <-p.shutdown:
- return
- case <-time.After(time.Second):
- case <-p.npSignal:
- }
-
- p.npMu.Lock()
- if len(p.npQueue) == 0 {
- p.npMu.Unlock()
- continue
- }
-
- // Keep a copy of the entries to process and clear the queue
- entries := p.npQueue
- p.npQueue = make(map[string]nowPlayingEntry)
- p.npMu.Unlock()
-
- // Process entries without holding lock
- for _, entry := range entries {
- p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
- }
- }
-}
-
-func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
- if t.Artist == consts.UnknownArtist {
- log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
- return
- }
- allScrobblers := p.getActiveScrobblers()
- for name, s := range allScrobblers {
- if !s.IsAuthorized(ctx, userId) {
- continue
- }
- log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
- err := s.NowPlaying(ctx, userId, t, position)
- if err != nil {
- log.Error(ctx, "Error sending PlaybackSession", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
- continue
- }
- }
-}
diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go
index 541a8e92b..3b71a2100 100644
--- a/core/scrobbler/play_tracker.go
+++ b/core/scrobbler/play_tracker.go
@@ -2,9 +2,8 @@ package scrobbler
import (
"context"
- "encoding/json"
"maps"
- "slices"
+ "sort"
"sync"
"time"
@@ -12,44 +11,19 @@ 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 (
- StateStarting = "starting"
- StatePlaying = "playing"
- StatePaused = "paused"
- StateStopped = "stopped"
- StateExpired = "expired"
-)
-
-var ValidStates = map[string]bool{
- StateStarting: true,
- StatePlaying: true,
- StatePaused: true,
- StateStopped: true,
-}
-
-type PlaybackSession struct {
- MediaFile model.MediaFile
- Start time.Time
- UserId string
- Username string
- PlayerId string
- PlayerName string
- State string
- 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 NowPlayingInfo struct {
+ MediaFile model.MediaFile
+ Start time.Time
+ Position int
+ Username string
+ PlayerId string
+ PlayerName string
}
type Submission struct {
@@ -57,33 +31,10 @@ type Submission struct {
Timestamp time.Time
}
-type ReportPlaybackParams struct {
- MediaId string
- PositionMs int64
- State string
- PlaybackRate float64
- IgnoreScrobble bool
- ClientId string
- ClientName string
-}
-
-type nowPlayingEntry struct {
- ctx context.Context //nolint:containedctx // queued work item carries the request ctx to the worker
- 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
-}
-
type PlayTracker interface {
- GetNowPlaying(ctx context.Context) ([]PlaybackSession, error)
+ NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
+ GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
Submit(ctx context.Context, submissions []Submission) error
- ReportPlayback(ctx context.Context, params ReportPlaybackParams) error
}
// PluginLoader is a minimal interface for plugin manager usage in PlayTracker
@@ -96,21 +47,11 @@ type PluginLoader interface {
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
+ playMap cache.SimpleCache[string, NowPlayingInfo]
builtinScrobblers map[string]Scrobbler
pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader
mu sync.RWMutex
- npQueue map[string]nowPlayingEntry
- npMu sync.Mutex
- npSignal chan struct{}
- shutdown chan struct{}
- workerDone chan struct{}
- prQueue []playbackReportEntry
- prMu sync.Mutex
- prSignal chan struct{}
- prWorkerDone chan struct{}
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
@@ -119,14 +60,10 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
})
}
-// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton,
-// returned by the GetPlayTracker function above. This constructor is exported for testing.
-func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
- return newPlayTracker(ds, broker, pluginManager)
-}
-
+// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
+// the GetPlayTracker function above
func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
- m := cache.NewSimpleCache[string, PlaybackSession]()
+ m := cache.NewSimpleCache[string, NowPlayingInfo]()
p := &playTracker{
ds: ds,
playMap: m,
@@ -134,28 +71,12 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
builtinScrobblers: make(map[string]Scrobbler),
pluginScrobblers: make(map[string]Scrobbler),
pluginLoader: pluginManager,
- npQueue: make(map[string]nowPlayingEntry),
- npSignal: make(chan struct{}, 1),
- shutdown: make(chan struct{}),
- workerDone: make(chan struct{}),
- prSignal: make(chan struct{}, 1),
- prWorkerDone: make(chan struct{}),
}
- enableNowPlaying := conf.Server.EnableNowPlaying
- m.OnExpiration(func(_ string, info PlaybackSession) {
- log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state",
- info.State, "username", info.Username, "userId", info.UserId)
- if enableNowPlaying {
+ if conf.Server.EnableNowPlaying {
+ m.OnExpiration(func(_ string, _ NowPlayingInfo) {
broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
- }
- ctx := request.WithUser(context.Background(), model.User{ID: info.UserId, UserName: info.Username})
- if info.State != StateStopped {
- log.Trace("Enqueueing PlaybackReport for expired session", "session", info)
- info.State = StateExpired
- info.LastReport = time.Now()
- p.enqueuePlaybackReport(ctx, info, info.filtered)
- }
- })
+ })
+ }
var enabled []string
for name, constructor := range constructors {
@@ -169,19 +90,10 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
p.builtinScrobblers[name] = s
}
log.Debug("List of builtin scrobblers enabled", "names", enabled)
- go p.nowPlayingWorker()
- go p.playbackReportWorker()
return p
}
-// stopBackgroundWorkers stops the background workers. This is primarily for testing.
-func (p *playTracker) stopBackgroundWorkers() {
- close(p.shutdown)
- <-p.workerDone // Wait for nowPlaying worker to finish
- <-p.prWorkerDone // Wait for playbackReport worker to finish
-}
-
-// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers.
+// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
if len(pluginNames) != len(scrobblers) {
return false
@@ -194,9 +106,7 @@ func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scro
return true
}
-// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers.
-// The buffered scrobblers use a loader function to dynamically get the current plugin instance,
-// so we only need to add/remove scrobblers when plugins are added/removed (not when reloaded).
+// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers
func (p *playTracker) refreshPluginScrobblers() {
p.mu.Lock()
defer p.mu.Unlock()
@@ -215,16 +125,15 @@ func (p *playTracker) refreshPluginScrobblers() {
// Build a set of current plugins for faster lookups
current := make(map[string]struct{}, len(pluginNames))
- // Process additions - add new plugins with a loader that dynamically fetches the current instance
+ // Process additions - add new plugins
for _, name := range pluginNames {
current[name] = struct{}{}
+ // Only create a new scrobbler if it doesn't exist
if _, exists := p.pluginScrobblers[name]; !exists {
- // Capture the name for the closure
- pluginName := name
- loader := p.pluginLoader
- p.pluginScrobblers[name] = newBufferedScrobblerWithLoader(p.ds, name, func() (Scrobbler, bool) {
- return loader.LoadScrobbler(pluginName)
- })
+ s, ok := p.pluginLoader.LoadScrobbler(name)
+ if ok && s != nil {
+ p.pluginScrobblers[name] = newBufferedScrobbler(p.ds, s, name)
+ }
}
}
@@ -258,212 +167,66 @@ 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
+func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error {
+ mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId)
+ if err != nil {
+ log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
+ return err
}
- remainingMs := float64(int64(durationSec*1000)-positionMs) / rate
- remainingSec := max(int(remainingMs/1000), 0)
- return time.Duration(remainingSec+5) * time.Second
-}
-func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error {
- player, _ := request.PlayerFrom(ctx)
user, _ := request.UserFrom(ctx)
- clientId := params.ClientId
- client := params.ClientName
-
- 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)
- if err != nil {
- return err
- }
- filtered = p.isFilteredOut(ctx, mf)
- info := PlaybackSession{
- MediaFile: *mf,
- filtered: filtered,
- Start: now,
- UserId: user.ID,
- Username: user.UserName,
- PlayerId: clientId,
- PlayerName: client,
- State: params.State,
- PositionMs: params.PositionMs,
- 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)
-
- 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)
- if err != nil {
- return err
- }
- info = PlaybackSession{
- MediaFile: *mf,
- Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond),
- UserId: user.ID,
- Username: user.UserName,
- PlayerId: clientId,
- PlayerName: client,
- }
- }
- info.State = params.State
- 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)
-
- case StateStopped:
- var loadedMF *model.MediaFile
- haveVerdict := false
- if !params.IgnoreScrobble && player.ScrobbleEnabled {
- mf, err := p.ds.MediaFile().GetWithParticipants(ctx, 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 {
- err = p.incPlay(ctx, mf, now)
- 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.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,
- PlayerId: clientId,
- PlayerName: client,
- State: params.State,
- PositionMs: params.PositionMs,
- PlaybackRate: params.PlaybackRate,
- LastReport: now,
- }
- if 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)
- if mfErr != nil {
- return mfErr
- }
- }
- stoppedInfo.MediaFile = *mf
- }
- if !haveVerdict {
- filtered = p.isFilteredOut(ctx, &stoppedInfo.MediaFile)
- }
- p.enqueuePlaybackReport(ctx, stoppedInfo, filtered)
+ info := NowPlayingInfo{
+ MediaFile: *mf,
+ Start: time.Now(),
+ Position: position,
+ Username: user.UserName,
+ PlayerId: playerId,
+ PlayerName: playerName,
}
+ // Calculate TTL based on remaining track duration. If position exceeds track duration,
+ // remaining is set to 0 to avoid negative TTL.
+ remaining := int(mf.Duration) - position
+ if remaining < 0 {
+ remaining = 0
+ }
+ // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
+ ttl := time.Duration(remaining+5) * time.Second
+ _ = p.playMap.AddWithTTL(playerId, info, ttl)
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 &&
- (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 {
- p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
- }
+ player, _ := request.PlayerFrom(ctx)
+ if player.ScrobbleEnabled {
+ p.dispatchNowPlaying(ctx, user.ID, mf, position)
}
-
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.
- 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)
- })
+func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
+ if t.Artist == consts.UnknownArtist {
+ log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
+ return
}
- slices.SortFunc(res, func(a, b PlaybackSession) int {
- return b.Start.Compare(a.Start)
- })
- for i := range res {
- if res[i].State == StatePlaying {
- elapsed := time.Since(res[i].LastReport).Milliseconds()
- estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate)
- trackDurationMs := int64(res[i].MediaFile.Duration * 1000)
- res[i].PositionMs = min(estimated, trackDurationMs)
+ allScrobblers := p.getActiveScrobblers()
+ for name, s := range allScrobblers {
+ if !s.IsAuthorized(ctx, userId) {
+ continue
+ }
+ log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
+ err := s.NowPlaying(ctx, userId, t, position)
+ if err != nil {
+ log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
+ continue
}
}
+}
+
+func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
+ res := p.playMap.Values()
+ sort.Slice(res, func(i, j int) bool {
+ return res[i].Start.After(res[j].Start)
+ })
return res, nil
}
@@ -477,12 +240,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 +253,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 +266,26 @@ 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)
- if err != nil {
- return err
- }
+ err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp)
}
- if conf.Server.EnableScrobbleHistory {
- return tx.Scrobble().RecordScrobble(ctx, track.ID, timestamp)
- }
- return nil
+ return err
})
}
-// 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 +311,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..7b4785bb5 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"
@@ -22,93 +20,53 @@ import (
. "github.com/onsi/gomega"
)
+// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests
+// Moved to top-level scope to avoid linter issues
+
type mockPluginLoader struct {
- mu sync.RWMutex
names []string
scrobblers map[string]Scrobbler
}
func (m *mockPluginLoader) PluginNames(service string) []string {
- m.mu.RLock()
- defer m.mu.RUnlock()
return m.names
}
-func (m *mockPluginLoader) SetNames(names []string) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.names = names
-}
-
func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
- m.mu.RLock()
- defer m.mu.RUnlock()
s, ok := m.scrobblers[name]
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
- var tracker *playTracker
+ var tracker PlayTracker
var eventBroker *fakeEventBroker
var track model.MediaFile
var album model.Album
var artist1 model.Artist
var artist2 model.Artist
- var fake *fakeScrobbler
+ var fake fakeScrobbler
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- ctx = GinkgoT().Context()
- ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}})
+ ctx = context.Background()
+ ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
- fake = &fakeScrobbler{Authorized: true}
+ fake = fakeScrobbler{Authorized: true}
Register("fake", func(model.DataStore) Scrobbler {
- return fake
+ return &fake
})
Register("disabled", func(model.DataStore) Scrobbler {
return nil
})
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
- tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests
+ tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
- LibraryID: 1,
Title: "Track Title",
Album: "Track Album",
AlbumID: "al-1",
@@ -119,29 +77,81 @@ 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)
- })
-
- AfterEach(func() {
- // Stop the worker goroutine to prevent data races between tests
- tracker.stopBackgroundWorkers()
+ _ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
})
It("does not register disabled scrobblers", func() {
- Expect(tracker.builtinScrobblers).To(HaveKey("fake"))
- Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled"))
+ Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
+ Expect(tracker.(*playTracker).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("NowPlaying", func() {
+ It("sends track to agent", func() {
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(fake.NowPlayingCalled).To(BeTrue())
+ Expect(fake.UserID).To(Equal("u-1"))
+ Expect(fake.Track.ID).To(Equal("123"))
+ Expect(fake.Track.Participants).To(Equal(track.Participants))
+ })
+ It("does not send track to agent if user has not authorized", func() {
+ fake.Authorized = false
+
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(fake.NowPlayingCalled).To(BeFalse())
+ })
+ It("does not send track to agent if player is not enabled to send scrobbles", func() {
+ ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
+
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(fake.NowPlayingCalled).To(BeFalse())
+ })
+ It("does not send track to agent if artist is unknown", func() {
+ track.Artist = consts.UnknownArtist
+
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(fake.NowPlayingCalled).To(BeFalse())
+ })
+
+ It("stores position when greater than zero", func() {
+ pos := 42
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
+ Expect(err).ToNot(HaveOccurred())
+
+ playing, err := tracker.GetNowPlaying(ctx)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(playing).To(HaveLen(1))
+ Expect(playing[0].Position).To(Equal(pos))
+ Expect(fake.Position).To(Equal(pos))
+ })
+
+ It("sends event with count", func() {
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ Expect(err).ToNot(HaveOccurred())
+ eventList := eventBroker.getEvents()
+ Expect(eventList).ToNot(BeEmpty())
+ evt, ok := eventList[0].(*events.NowPlayingCount)
+ Expect(ok).To(BeTrue())
+ Expect(evt.Count).To(Equal(1))
+ })
+
+ It("does not send event when disabled", func() {
+ conf.Server.EnableNowPlaying = false
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(eventBroker.getEvents()).To(BeEmpty())
})
})
@@ -149,17 +159,11 @@ var _ = Describe("PlayTracker", func() {
It("returns current playing music", func() {
track2 := track
track2.ID = "456"
- _ = ds.MediaFile().Put(ctx, &track2)
- ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
- ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true})
- _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one",
- })
- ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
- ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true})
- _ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
- MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
- })
+ _ = ds.MediaFile(ctx).Put(&track2)
+ ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"})
+ _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
+ _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
playing, err := tracker.GetNowPlaying(ctx)
@@ -175,52 +179,12 @@ 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() {
It("sends event when entry expires", func() {
- info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
- _ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
+ info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
+ _ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0))
eventList := eventBroker.getEvents()
evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount)
@@ -231,48 +195,10 @@ var _ = Describe("PlayTracker", func() {
It("does not send event when disabled", func() {
conf.Server.EnableNowPlaying = false
tracker = newPlayTracker(ds, eventBroker, nil)
- info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
- _ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
+ info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
+ _ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0))
})
-
- It("sends expired playback report when session expires", func() {
- info := PlaybackSession{
- MediaFile: track,
- Start: time.Now(),
- UserId: "u-1",
- Username: "user",
- PlayerId: "player-3",
- PlayerName: "test-player",
- State: StatePlaying,
- PositionMs: 5000,
- }
- _ = tracker.playMap.AddWithTTL("player-3", info, 10*time.Millisecond)
- Eventually(func() *PlaybackSession {
- return fake.LastPlaybackReport.Load()
- }).ShouldNot(BeNil())
- report := fake.LastPlaybackReport.Load()
- Expect(report.State).To(Equal(StateExpired))
- Expect(report.MediaFile.ID).To(Equal("123"))
- Expect(report.PlayerId).To(Equal("player-3"))
- })
-
- It("does not send expired report when session was already stopped", func() {
- info := PlaybackSession{
- MediaFile: track,
- Start: time.Now(),
- UserId: "u-1",
- Username: "user",
- PlayerId: "player-4",
- PlayerName: "test-player",
- State: StateStopped,
- PositionMs: 180000,
- }
- _ = tracker.playMap.AddWithTTL("player-4", info, 10*time.Millisecond)
- Consistently(func() *PlaybackSession {
- return fake.LastPlaybackReport.Load()
- }).Should(BeNil())
- })
})
Describe("Submit", func() {
@@ -284,7 +210,7 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
- Expect(fake.GetUserID()).To(Equal("u-1"))
+ Expect(fake.UserID).To(Equal("u-1"))
lastScrobble := fake.LastScrobble.Load()
Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second))
Expect(lastScrobble.ID).To(Equal("123"))
@@ -334,7 +260,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()}})
@@ -348,912 +274,49 @@ var _ = Describe("PlayTracker", func() {
Expect(artist1.PlayCount).To(Equal(int64(1)))
Expect(artist2.PlayCount).To(Equal(int64(1)))
})
-
- Context("Scrobble History", func() {
- It("records scrobble in repository", func() {
- conf.Server.EnableScrobbleHistory = true
- ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
- ts := time.Now()
-
- err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
-
- Expect(err).ToNot(HaveOccurred())
-
- mockDS := ds.(*tests.MockDataStore)
- mockScrobble := mockDS.Scrobble().(*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()))
- })
-
- It("does not record scrobble when history is disabled", func() {
- conf.Server.EnableScrobbleHistory = false
- ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
- ts := time.Now()
-
- err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
-
- Expect(err).ToNot(HaveOccurred())
- mockDS := ds.(*tests.MockDataStore)
- mockScrobble := mockDS.Scrobble().(*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"
-
- BeforeEach(func() {
- ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true})
- })
-
- It("creates entry on starting and removes on stopped", func() {
- 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("starting"))
- Expect(playing[0].MediaFile.ID).To(Equal("123"))
-
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
- IgnoreScrobble: true,
- })
- Expect(err).ToNot(HaveOccurred())
-
- playing, err = tracker.GetNowPlaying(ctx)
- Expect(err).ToNot(HaveOccurred())
- Expect(playing).To(BeEmpty())
- })
-
- It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 10000, State: "playing", 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(10000)))
-
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- playing, err = tracker.GetNowPlaying(ctx)
- Expect(err).ToNot(HaveOccurred())
- Expect(playing[0].State).To(Equal("paused"))
- Expect(playing[0].PositionMs).To(Equal(int64(30000)))
-
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
-
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- playing, err = tracker.GetNowPlaying(ctx)
- Expect(err).ToNot(HaveOccurred())
- 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)
-
- 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,
- })
- 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)))
- })
-
- It("multiple players have independent sessions", func() {
- ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
- ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true})
-
- ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
- ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true})
-
- track2 := track
- track2.ID = "456"
- _ = ds.MediaFile().Put(ctx, &track2)
-
- err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1",
- })
- Expect(err).ToNot(HaveOccurred())
- err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
- MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2",
- })
- Expect(err).ToNot(HaveOccurred())
- playing, err := tracker.GetNowPlaying(ctx)
- Expect(err).ToNot(HaveOccurred())
- Expect(playing).To(HaveLen(2))
- })
-
- Describe("SSE broadcast on state change", func() {
- BeforeEach(func() {
- eventBroker = &fakeEventBroker{}
- tracker = newPlayTracker(ds, eventBroker, nil)
- tracker.builtinScrobblers["fake"] = fake
- })
-
- It("broadcasts NowPlayingCount on every state change", func() {
- // starting -> count should be 1
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- evts := eventBroker.getEvents()
- Expect(evts).To(HaveLen(1))
- Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1))
-
- // playing -> count should be 1
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- evts = eventBroker.getEvents()
- Expect(evts).To(HaveLen(2))
- Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1))
-
- // paused -> count should be 1
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- evts = eventBroker.getEvents()
- Expect(evts).To(HaveLen(3))
- Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1))
-
- // stopped -> count should be 0
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
- IgnoreScrobble: true,
- })
- Expect(err).ToNot(HaveOccurred())
- evts = eventBroker.getEvents()
- Expect(evts).To(HaveLen(4))
- Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0))
- })
-
- It("does NOT broadcast when EnableNowPlaying is false", func() {
- conf.Server.EnableNowPlaying = false
- tracker = newPlayTracker(ds, eventBroker, nil)
- tracker.builtinScrobblers["fake"] = fake
-
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- Expect(eventBroker.getEvents()).To(BeEmpty())
- })
- })
-
- Describe("auto-scrobble", func() {
- It("scrobbles on stopped when positionMs >= 50% of track", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", 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)))
- Expect(album.PlayCount).To(Equal(int64(1)))
- Expect(artist1.PlayCount).To(Equal(int64(1)))
- })
-
- It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() {
- longTrack := model.MediaFile{
- ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1",
- Duration: 600,
- Participants: map[model.Role]model.ParticipantList{
- model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")},
- },
- }
- _ = ds.MediaFile().Put(ctx, &longTrack)
-
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- Expect(longTrack.PlayCount).To(Equal(int64(1)))
- })
-
- It("does NOT scrobble when positionMs below threshold", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- Expect(track.PlayCount).To(Equal(int64(0)))
- })
-
- 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,
- })
- Expect(err).ToNot(HaveOccurred())
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
- IgnoreScrobble: true,
- })
- 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() {
- ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
-
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", 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(0)))
- })
-
- It("scrobbles twice for two separate sessions of same song", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", 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())
- err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", 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(2)))
- })
-
- It("dispatches to external scrobblers on auto-scrobble", func() {
- fake.ScrobbleCalled.Store(false)
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", 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(fake.ScrobbleCalled.Load()).To(BeTrue())
- })
- })
-
- Describe("position estimation", func() {
- It("estimates position for playing state based on elapsed time", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- time.Sleep(50 * time.Millisecond)
- playing, err := tracker.GetNowPlaying(ctx)
- Expect(err).ToNot(HaveOccurred())
- Expect(playing).To(HaveLen(1))
- Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000)))
- })
-
- It("does NOT estimate for paused", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- time.Sleep(50 * time.Millisecond)
- playing, err := tracker.GetNowPlaying(ctx)
- Expect(err).ToNot(HaveOccurred())
- Expect(playing).To(HaveLen(1))
- Expect(playing[0].PositionMs).To(Equal(int64(10000)))
- })
-
- It("does NOT estimate for starting", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- time.Sleep(50 * time.Millisecond)
- playing, err := tracker.GetNowPlaying(ctx)
- Expect(err).ToNot(HaveOccurred())
- Expect(playing).To(HaveLen(1))
- Expect(playing[0].PositionMs).To(Equal(int64(0)))
- })
-
- It("respects playbackRate", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- time.Sleep(100 * time.Millisecond)
- playing, err := tracker.GetNowPlaying(ctx)
- Expect(err).ToNot(HaveOccurred())
- Expect(playing).To(HaveLen(1))
- // At 2x speed, 100ms real time = ~200ms playback time
- Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100)))
- })
-
- It("caps estimated position at track duration", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- time.Sleep(50 * time.Millisecond)
- playing, err := tracker.GetNowPlaying(ctx)
- Expect(err).ToNot(HaveOccurred())
- Expect(playing).To(HaveLen(1))
- Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000
- })
-
- })
-
- Describe("resilience (no prior starting)", func() {
- It("playing without prior starting creates entry with Start approx now - positionMs", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 30000, State: "playing", 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"))
- expectedStart := time.Now().Add(-30 * time.Second)
- Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second))
- })
-
- It("paused without prior starting creates entry", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 30000, State: "paused", 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("paused"))
- })
-
- It("stopped without prior starting auto-scrobbles if threshold met", func() {
- 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("stopped without prior starting does NOT scrobble if below threshold", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- Expect(track.PlayCount).To(Equal(int64(0)))
- })
- })
-
- 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)
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
- })
-
- It("dispatches NowPlaying on playing", func() {
- fake.nowPlayingCalled.Store(false)
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
- })
-
- It("does NOT dispatch on paused", func() {
- fake.nowPlayingCalled.Store(false)
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
- })
- Expect(err).ToNot(HaveOccurred())
- Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
- })
-
- It("still dispatches NowPlaying when ignoreScrobble=true", func() {
- fake.nowPlayingCalled.Store(false)
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
- IgnoreScrobble: true,
- })
- Expect(err).ToNot(HaveOccurred())
- Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
- })
-
- It("does NOT dispatch when ScrobbleEnabled=false", func() {
- fake.nowPlayingCalled.Store(false)
- ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: 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())
- })
- })
-
- Describe("PlaybackReport dispatch", func() {
- It("dispatches PlaybackReport for starting state", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
- ClientId: "client-1", ClientName: "Test Player",
- })
- Expect(err).ToNot(HaveOccurred())
-
- Eventually(func() bool {
- return fake.PlaybackReportCalled.Load()
- }).Should(BeTrue())
-
- info := fake.LastPlaybackReport.Load()
- Expect(info).ToNot(BeNil())
- Expect(info.MediaFile.ID).To(Equal("123"))
- Expect(info.State).To(Equal(StateStarting))
- Expect(info.PositionMs).To(Equal(int64(0)))
- Expect(info.PlaybackRate).To(Equal(1.0))
- Expect(info.PlayerId).To(Equal("client-1"))
- Expect(info.PlayerName).To(Equal("Test Player"))
- })
-
- It("dispatches PlaybackReport for playing state", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
- ClientId: "client-1", ClientName: "Test Player",
- })
- 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: 30000, State: StatePlaying, PlaybackRate: 1.5,
- ClientId: "client-1", ClientName: "Test Player",
- })
- Expect(err).ToNot(HaveOccurred())
-
- Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
- info := fake.LastPlaybackReport.Load()
- Expect(info.State).To(Equal(StatePlaying))
- Expect(info.PositionMs).To(Equal(int64(30000)))
- Expect(info.PlaybackRate).To(Equal(1.5))
- })
-
- It("dispatches PlaybackReport for paused state", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
- ClientId: "client-1", ClientName: "Test Player",
- })
- 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: 45000, State: StatePaused, PlaybackRate: 1.0,
- ClientId: "client-1", ClientName: "Test Player",
- })
- Expect(err).ToNot(HaveOccurred())
-
- Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
- info := fake.LastPlaybackReport.Load()
- Expect(info.State).To(Equal(StatePaused))
- Expect(info.PositionMs).To(Equal(int64(45000)))
- })
-
- It("dispatches PlaybackReport for stopped state", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
- ClientId: "client-1", ClientName: "Test Player",
- })
- 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: StateStopped, PlaybackRate: 1.0,
- ClientId: "client-1", ClientName: "Test Player",
- })
- Expect(err).ToNot(HaveOccurred())
-
- Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
- info := fake.LastPlaybackReport.Load()
- Expect(info.State).To(Equal(StateStopped))
- Expect(info.PositionMs).To(Equal(int64(100000)))
- })
- })
})
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
- var pluginFake *fakeScrobbler
+ var pluginFake fakeScrobbler
BeforeEach(func() {
- pluginFake = &fakeScrobbler{Authorized: true}
+ pluginFake = fakeScrobbler{Authorized: true}
pluginLoader = &mockPluginLoader{
names: []string{"plugin1"},
- scrobblers: map[string]Scrobbler{"plugin1": pluginFake},
+ scrobblers: map[string]Scrobbler{"plugin1": &pluginFake},
}
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
// Bypass buffering for both built-in and plugin scrobblers
- tracker.builtinScrobblers["fake"] = fake
- tracker.pluginScrobblers["plugin1"] = pluginFake
+ tracker.(*playTracker).builtinScrobblers["fake"] = &fake
+ tracker.(*playTracker).pluginScrobblers["plugin1"] = &pluginFake
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
- })
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
+ Expect(pluginFake.NowPlayingCalled).To(BeTrue())
})
It("removes plugin scrobbler if not present anymore", func() {
- _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
- })
- Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
- pluginFake.nowPlayingCalled.Store(false)
- pluginLoader.SetNames([]string{})
- _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
- })
- Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
+ // First call: plugin present
+ _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ Expect(pluginFake.NowPlayingCalled).To(BeTrue())
+ pluginFake.NowPlayingCalled = false
+ // Remove plugin
+ pluginLoader.names = []string{}
+ _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
+ Expect(pluginFake.NowPlayingCalled).To(BeFalse())
})
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
- fake.nowPlayingCalled.Store(false)
- pluginFake.nowPlayingCalled.Store(false)
- err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
- MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
- })
+ fake.NowPlayingCalled = false
+ pluginFake.NowPlayingCalled = false
+ err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
- Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
+ Expect(fake.NowPlayingCalled).To(BeTrue())
+ Expect(pluginFake.NowPlayingCalled).To(BeTrue())
})
It("calls plugin scrobbler for Submit", func() {
@@ -1271,7 +334,7 @@ var _ = Describe("PlayTracker", func() {
var mockedBS *mockBufferedScrobbler
BeforeEach(func() {
- ctx = GinkgoT().Context()
+ ctx = context.Background()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
@@ -1296,7 +359,7 @@ var _ = Describe("PlayTracker", func() {
It("calls Stop on scrobblers when removing them", func() {
// Change the plugin names to simulate a plugin being removed
- mockPlugin.SetNames([]string{})
+ mockPlugin.names = []string{}
// Call refreshPluginScrobblers which should detect the removed plugin
pTracker.refreshPluginScrobblers()
@@ -1308,252 +371,44 @@ var _ = Describe("PlayTracker", func() {
Expect(pTracker.pluginScrobblers).NotTo(HaveKey("plugin1"))
})
})
-
- Describe("Plugin reload (config update) behavior", func() {
- var mockPlugin *mockPluginLoader
- var pTracker *playTracker
- var originalScrobbler *fakeScrobbler
- var reloadedScrobbler *fakeScrobbler
-
- BeforeEach(func() {
- ctx = GinkgoT().Context()
- ctx = request.WithUser(ctx, model.User{ID: "u-1"})
- ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
- ds = &tests.MockDataStore{}
-
- // Setup initial plugin scrobbler
- originalScrobbler = &fakeScrobbler{Authorized: true}
- reloadedScrobbler = &fakeScrobbler{Authorized: true}
-
- mockPlugin = &mockPluginLoader{
- names: []string{"plugin1"},
- scrobblers: map[string]Scrobbler{"plugin1": originalScrobbler},
- }
-
- // Create tracker - this will create buffered scrobblers with loaders
- pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin)
-
- // Trigger initial plugin registration
- pTracker.refreshPluginScrobblers()
- })
-
- AfterEach(func() {
- pTracker.stopBackgroundWorkers()
- })
-
- It("uses the new plugin instance after reload (simulating config update)", func() {
- // First call should use the original scrobbler
- scrobblers := pTracker.getActiveScrobblers()
- pluginScr := scrobblers["plugin1"]
- Expect(pluginScr).ToNot(BeNil())
-
- err := pluginScr.NowPlaying(ctx, "u-1", &track, 0)
- Expect(err).ToNot(HaveOccurred())
- Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue())
- Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeFalse())
-
- // Simulate plugin reload (config update): replace the scrobbler in the loader
- // This is what happens when UpdatePluginConfig is called - the plugin manager
- // unloads the old plugin and loads a new instance
- mockPlugin.mu.Lock()
- mockPlugin.scrobblers["plugin1"] = reloadedScrobbler
- mockPlugin.mu.Unlock()
-
- // Reset call tracking
- originalScrobbler.nowPlayingCalled.Store(false)
-
- // Get scrobblers again - should still return the same buffered scrobbler
- // but subsequent calls should use the new plugin instance via the loader
- scrobblers = pTracker.getActiveScrobblers()
- pluginScr = scrobblers["plugin1"]
-
- err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
- Expect(err).ToNot(HaveOccurred())
-
- // The new scrobbler should be called, not the old one
- Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue())
- Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse())
- })
-
- It("handles plugin becoming unavailable temporarily", func() {
- // First verify plugin works
- scrobblers := pTracker.getActiveScrobblers()
- pluginScr := scrobblers["plugin1"]
-
- err := pluginScr.NowPlaying(ctx, "u-1", &track, 0)
- Expect(err).ToNot(HaveOccurred())
- Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue())
-
- // Simulate plugin becoming unavailable (e.g., during reload)
- mockPlugin.mu.Lock()
- delete(mockPlugin.scrobblers, "plugin1")
- mockPlugin.mu.Unlock()
-
- originalScrobbler.nowPlayingCalled.Store(false)
-
- // NowPlaying should return error when plugin unavailable
- err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
- Expect(err).To(HaveOccurred())
- Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse())
-
- // Simulate plugin becoming available again
- mockPlugin.mu.Lock()
- mockPlugin.scrobblers["plugin1"] = reloadedScrobbler
- mockPlugin.mu.Unlock()
-
- // Should work again with new instance
- err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
- Expect(err).ToNot(HaveOccurred())
- Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue())
- })
-
- It("IsAuthorized uses the current plugin instance", func() {
- scrobblers := pTracker.getActiveScrobblers()
- pluginScr := scrobblers["plugin1"]
-
- // Original is authorized
- Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeTrue())
-
- // Replace with unauthorized scrobbler
- unauthorizedScrobbler := &fakeScrobbler{Authorized: false}
- mockPlugin.mu.Lock()
- mockPlugin.scrobblers["plugin1"] = unauthorizedScrobbler
- mockPlugin.mu.Unlock()
-
- // Should reflect the new scrobbler's authorization status
- Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeFalse())
- })
- })
})
-var _ = DescribeTable("remainingTTL",
- func(durationSec float32, positionMs int64, rate float64, expected time.Duration) {
- Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected))
- },
- Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second),
- Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second),
- Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second),
- Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second),
- Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second),
- Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second),
- Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second),
- Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second),
- Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second),
- Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second),
- Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second),
- Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second),
-)
-
type fakeScrobbler struct {
- Authorized bool
- nowPlayingCalled atomic.Bool
- ScrobbleCalled atomic.Bool
- PlaybackReportCalled atomic.Bool
- userID atomic.Pointer[string]
- username atomic.Pointer[string]
- track atomic.Pointer[model.MediaFile]
- 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()
-}
-
-func (f *fakeScrobbler) GetNowPlayingCalled() bool {
- return f.nowPlayingCalled.Load()
-}
-
-func (f *fakeScrobbler) GetUserID() string {
- if p := f.userID.Load(); p != nil {
- return *p
- }
- 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()
+ Authorized bool
+ NowPlayingCalled bool
+ ScrobbleCalled atomic.Bool
+ UserID string
+ Track *model.MediaFile
+ Position int
+ LastScrobble atomic.Pointer[Scrobble]
+ Error error
}
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
+ f.NowPlayingCalled = true
+ if f.Error != nil {
+ return f.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.track.Store(track)
- f.position.Store(int32(position))
+ f.UserID = userId
+ f.Track = track
+ f.Position = position
return nil
}
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.UserID = userId
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
- }
- f.userID.Store(new(info.UserId))
- f.LastPlaybackReport.Store(&info)
- return nil
-}
-
func _p(id, name string, sortName ...string) model.Participant {
p := model.Participant{Artist: model.Artist{ID: id, Name: name}}
if len(sortName) > 0 {
@@ -1609,7 +464,3 @@ func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, t
func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
return m.wrapped.Scrobble(ctx, userId, s)
}
-
-func (m *mockBufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
- return m.wrapped.PlaybackReport(ctx, info)
-}
diff --git a/core/scrobbler/playbackreport_worker.go b/core/scrobbler/playbackreport_worker.go
deleted file mode 100644
index 799dac616..000000000
--- a/core/scrobbler/playbackreport_worker.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package scrobbler
-
-import (
- "context"
-
- "github.com/navidrome/navidrome/log"
-)
-
-func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession, filtered bool) {
- p.prMu.Lock()
- defer p.prMu.Unlock()
- ctx = context.WithoutCancel(ctx)
- p.prQueue = append(p.prQueue, playbackReportEntry{
- ctx: ctx,
- info: info,
- filtered: filtered,
- })
- p.sendPlaybackReportSignal()
-}
-
-func (p *playTracker) sendPlaybackReportSignal() {
- select {
- case p.prSignal <- struct{}{}:
- default:
- }
-}
-
-func (p *playTracker) playbackReportWorker() {
- defer close(p.prWorkerDone)
- for {
- select {
- case <-p.shutdown:
- return
- case <-p.prSignal:
- }
-
- p.prMu.Lock()
- if len(p.prQueue) == 0 {
- p.prMu.Unlock()
- continue
- }
- entries := p.prQueue
- p.prQueue = nil
- p.prMu.Unlock()
-
- allScrobblers := p.getActiveScrobblers()
- for _, entry := range entries {
- p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers, entry.filtered)
- }
- }
-}
-
-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
- }
- for name, s := range allScrobblers {
- if !s.IsAuthorized(ctx, info.UserId) {
- continue
- }
- log.Debug(ctx, "Sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, "positionMs", info.PositionMs)
- err := s.PlaybackReport(ctx, info)
- if err != nil {
- log.Error(ctx, "Error sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, err)
- continue
- }
- }
-}
diff --git a/core/share.go b/core/share.go
index 33c8996ef..eb5e6679b 100644
--- a/core/share.go
+++ b/core/share.go
@@ -2,42 +2,38 @@ package core
import (
"context"
- "fmt"
- "slices"
"strings"
"time"
"github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
+ gonanoid "github.com/matoous/go-nanoid/v2"
"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"
"github.com/navidrome/navidrome/utils/str"
)
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)
+ id, err := gonanoid.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 interface{}) (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 interface{}, _ ...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
deleted file mode 100644
index 8645d3d28..000000000
--- a/core/sonic/sonic.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package sonic
-
-import (
- "context"
- "fmt"
-
- "github.com/navidrome/navidrome/core/agents"
- "github.com/navidrome/navidrome/core/matcher"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
-)
-
-const capabilitySonicSimilarity = "SonicSimilarity"
-
-type SimilarResult struct {
- Song agents.Song
- Similarity float64
-}
-
-type SimilarMatch struct {
- MediaFile model.MediaFile
- Similarity float64
-}
-
-type Provider interface {
- GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error)
- FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error)
-}
-
-type PluginLoader interface {
- PluginNames(capability string) []string
- LoadSonicSimilarity(name string) (Provider, bool)
-}
-
-type Sonic struct {
- ds model.DataStore
- pluginLoader PluginLoader
- matcher *matcher.Matcher
-}
-
-func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic {
- return &Sonic{
- ds: ds,
- pluginLoader: pluginLoader,
- 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
-}
-
-func (s *Sonic) loadProvider() (Provider, error) {
- names := s.pluginLoader.PluginNames(capabilitySonicSimilarity)
- if len(names) == 0 {
- return nil, model.ErrNotFound
- }
- provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0])
- if !ok {
- return nil, model.ErrNotFound
- }
- return provider, nil
-}
-
-func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) {
- songs := make([]agents.Song, len(results))
- for i, r := range results {
- songs[i] = r.Song
- }
-
- matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs)
- if err != nil {
- return nil, fmt.Errorf("matching songs to library: %w", err)
- }
-
- var matches []SimilarMatch
- for i, r := range results {
- if mf, ok := matchMap[i]; ok {
- matches = append(matches, SimilarMatch{
- MediaFile: mf,
- Similarity: r.Similarity,
- })
- }
- }
- return matches, nil
-}
-
-func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) {
- provider, err := s.loadProvider()
- if err != nil {
- return nil, err
- }
-
- mf, err := s.ds.MediaFile().Get(ctx, id)
- if err != nil {
- return nil, fmt.Errorf("getting media file %s: %w", id, err)
- }
-
- results, err := provider.GetSonicSimilarTracks(ctx, mf, count)
- if err != nil {
- log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err)
- return nil, err
- }
-
- return s.resolveMatches(ctx, results)
-}
-
-func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) {
- provider, err := s.loadProvider()
- if err != nil {
- return nil, err
- }
-
- startMF, err := s.ds.MediaFile().Get(ctx, startID)
- if err != nil {
- return nil, fmt.Errorf("getting start media file %s: %w", startID, err)
- }
- endMF, err := s.ds.MediaFile().Get(ctx, endID)
- if err != nil {
- return nil, fmt.Errorf("getting end media file %s: %w", endID, err)
- }
-
- results, err := provider.FindSonicPath(ctx, startMF, endMF, count)
- if err != nil {
- log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err)
- return nil, err
- }
-
- return s.resolveMatches(ctx, results)
-}
diff --git a/core/sonic/sonic_suite_test.go b/core/sonic/sonic_suite_test.go
deleted file mode 100644
index 7058e5de9..000000000
--- a/core/sonic/sonic_suite_test.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package sonic_test
-
-import (
- "testing"
-
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-func TestSonic(t *testing.T) {
- tests.Init(t, false)
- log.SetLevel(log.LevelFatal)
- RegisterFailHandler(Fail)
- RunSpecs(t, "Sonic Suite")
-}
diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go
deleted file mode 100644
index 813fceea9..000000000
--- a/core/sonic/sonic_test.go
+++ /dev/null
@@ -1,146 +0,0 @@
-package sonic_test
-
-import (
- "context"
- "errors"
-
- "github.com/navidrome/navidrome/core/agents"
- "github.com/navidrome/navidrome/core/matcher"
- "github.com/navidrome/navidrome/core/sonic"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-type mockPluginLoader struct {
- names []string
- provider sonic.Provider
- loadOk bool
-}
-
-func (m *mockPluginLoader) PluginNames(capability string) []string {
- if capability == "SonicSimilarity" {
- return m.names
- }
- return nil
-}
-
-func (m *mockPluginLoader) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
- return m.provider, m.loadOk
-}
-
-type mockProvider struct {
- similarResults []sonic.SimilarResult
- similarErr error
- pathResults []sonic.SimilarResult
- pathErr error
-}
-
-func (m *mockProvider) GetSonicSimilarTracks(_ context.Context, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
- return m.similarResults, m.similarErr
-}
-
-func (m *mockProvider) FindSonicPath(_ context.Context, _, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
- return m.pathResults, m.pathErr
-}
-
-var _ = Describe("Sonic", func() {
- var (
- ctx context.Context
- ds *tests.MockDataStore
- loader *mockPluginLoader
- service *sonic.Sonic
- )
-
- BeforeEach(func() {
- ctx = GinkgoT().Context()
- ds = &tests.MockDataStore{}
- loader = &mockPluginLoader{}
- })
-
- Describe("HasProvider", func() {
- It("returns false when no plugins available", func() {
- loader.names = nil
- service = sonic.New(ds, loader, nil)
- Expect(service.HasProvider()).To(BeFalse())
- })
-
- It("returns true when a plugin is available", func() {
- loader.names = []string{"test-plugin"}
- service = sonic.New(ds, loader, nil)
- Expect(service.HasProvider()).To(BeTrue())
- })
- })
-
- Describe("GetSonicSimilarTracks", func() {
- It("returns error when no plugin available", func() {
- loader.names = nil
- service = sonic.New(ds, loader, nil)
- _, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
- Expect(err).To(MatchError(model.ErrNotFound))
- })
-
- It("returns error when media file not found", func() {
- loader.names = []string{"test-plugin"}
- loader.provider = &mockProvider{}
- loader.loadOk = true
- ds.MockedMediaFile = &tests.MockMediaFileRepo{}
- service = sonic.New(ds, loader, matcher.New(ds))
- _, err := service.GetSonicSimilarTracks(ctx, "nonexistent", 10)
- Expect(err).To(HaveOccurred())
- })
-
- It("returns matched results from plugin", func() {
- mf1 := model.MediaFile{ID: "song-1", Title: "Test Song", Artist: "Test Artist"}
- mf2 := model.MediaFile{ID: "song-2", Title: "Similar Song", Artist: "Test Artist"}
-
- mockRepo := tests.CreateMockMediaFileRepo()
- mockRepo.SetData(model.MediaFiles{mf1, mf2})
- ds.MockedMediaFile = mockRepo
-
- provider := &mockProvider{
- similarResults: []sonic.SimilarResult{
- {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}}, Similarity: 0.85},
- },
- }
- loader.names = []string{"test-plugin"}
- loader.provider = provider
- loader.loadOk = true
-
- service = sonic.New(ds, loader, matcher.New(ds))
- matches, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
- Expect(err).ToNot(HaveOccurred())
- Expect(matches).To(HaveLen(1))
- Expect(matches[0].MediaFile.ID).To(Equal("song-2"))
- Expect(matches[0].Similarity).To(Equal(0.85))
- })
- })
-
- Describe("FindSonicPath", func() {
- It("returns error when no plugin available", func() {
- loader.names = nil
- service = sonic.New(ds, loader, nil)
- _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
- Expect(err).To(MatchError(model.ErrNotFound))
- })
-
- It("returns error when plugin call fails", func() {
- mf1 := model.MediaFile{ID: "song-1", Title: "Start", Artist: "Artist"}
- mf2 := model.MediaFile{ID: "song-2", Title: "End", Artist: "Artist"}
-
- mockRepo := tests.CreateMockMediaFileRepo()
- mockRepo.SetData(model.MediaFiles{mf1, mf2})
- ds.MockedMediaFile = mockRepo
-
- provider := &mockProvider{pathErr: errors.New("plugin error")}
- loader.names = []string{"test-plugin"}
- loader.provider = provider
- loader.loadOk = true
-
- service = sonic.New(ds, loader, matcher.New(ds))
- _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
- Expect(err).To(HaveOccurred())
- })
- })
-})
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 e2ce00a1b..5c335ddb9 100644
--- a/core/storage/local/local.go
+++ b/core/storage/local/local.go
@@ -6,13 +6,11 @@ import (
"net/url"
"os"
"path/filepath"
- "sync"
"sync/atomic"
"time"
"github.com/djherbis/times"
"github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
@@ -30,13 +28,7 @@ type localStorage struct {
func newLocalStorage(u url.URL) storage.Storage {
newExtractor, ok := extractors[conf.Server.Scanner.Extractor]
if !ok || newExtractor == nil {
- if conf.Server.Scanner.Extractor != consts.DefaultScannerExtractor {
- log.Warn("Extractor not found, using default", "extractor", conf.Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
- }
- newExtractor = extractors[consts.DefaultScannerExtractor]
- if newExtractor == nil {
- log.Fatal("Default extractor not registered", "extractor", consts.DefaultScannerExtractor)
- }
+ log.Fatal("Extractor not found", "path", conf.Server.Scanner.Extractor)
}
isWindowsPath := filepath.VolumeName(u.Host) != ""
if u.Scheme == storage.LocalSchemaID && isWindowsPath {
@@ -52,39 +44,15 @@ func newLocalStorage(u url.URL) storage.Storage {
func (s *localStorage) FS() (storage.MusicFS, error) {
path := s.u.Path
- if _, err := os.Stat(path); err != nil { //nolint:gosec
+ if _, err := os.Stat(path); err != nil {
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)))
-}
-
-// ReadLink and Lstat implement fs.ReadLinkFS, so callers can detect symlinks without following them.
-var _ fs.ReadLinkFS = (*localFS)(nil)
-
-func (lfs *localFS) ReadLink(name string) (string, error) {
- return fs.ReadLink(lfs.FS, name)
-}
-
-func (lfs *localFS) Lstat(name string) (fs.FileInfo, error) {
- return fs.Lstat(lfs.FS, name)
}
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
@@ -98,11 +66,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
}
}
@@ -113,46 +77,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..3ed01bbc4 100644
--- a/core/storage/local/local_test.go
+++ b/core/storage/local/local_test.go
@@ -6,13 +6,10 @@ 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/onsi/ginkgo/v2"
@@ -46,13 +43,12 @@ var _ = Describe("LocalStorage", func() {
})
Describe("newLocalStorage", func() {
-
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 +88,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 +104,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,49 +131,31 @@ 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"))
})
})
- Context("when the configured extractor is not registered", func() {
- var defaultExtractor *mockTestExtractor
+ Context("with invalid extractor", func() {
+ It("should handle extractor validation correctly", func() {
+ // Note: The actual implementation uses log.Fatal which exits the process,
+ // so we test the normal path where extractors exist
- BeforeEach(func() {
- defaultExtractor = &mockTestExtractor{results: make(map[string]metadata.Info)}
- RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) Extractor {
- return defaultExtractor
- })
- DeferCleanup(func() {
- lock.Lock()
- delete(extractors, consts.DefaultScannerExtractor)
- lock.Unlock()
- })
- })
-
- 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)
- ls, ok := storage.(*localStorage)
- Expect(ok).To(BeTrue())
- Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor))
+ storage := newLocalStorage(*u)
+ Expect(storage).ToNot(BeNil())
})
})
})
Describe("localStorage.FS", func() {
-
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 +168,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,78 +179,6 @@ 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
@@ -303,9 +209,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 +233,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 +262,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 +277,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 +308,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 +348,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() {
@@ -490,7 +365,7 @@ var _ = Describe("LocalStorage", func() {
Describe("Storage registration", func() {
It("should register localStorage for file scheme", func() {
// 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..e2418f4cb 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"
@@ -18,19 +17,23 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) {
if !s.watching.CompareAndSwap(false, true) {
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)
- }
+ input := make(chan notify.EventInfo, 1)
+ output := make(chan string, 1)
- 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..60496e611 100644
--- a/core/storage/storage_test.go
+++ b/core/storage/storage_test.go
@@ -4,7 +4,6 @@ import (
"net/url"
"os"
"path/filepath"
- "runtime"
"testing"
. "github.com/onsi/ginkgo/v2"
@@ -59,12 +58,8 @@ var _ = Describe("Storage", func() {
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 +81,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/storage/storagetest/fake_storage.go b/core/storage/storagetest/fake_storage.go
index 1b0d1a6c1..009b37d2d 100644
--- a/core/storage/storagetest/fake_storage.go
+++ b/core/storage/storagetest/fake_storage.go
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io/fs"
- "maps"
"net/url"
"path"
"testing/fstest"
@@ -136,7 +135,9 @@ func (ffs *FakeFS) UpdateTags(filePath string, newTags map[string]any, when ...t
if err != nil {
panic(err)
}
- maps.Copy(tags, newTags)
+ for k, v := range newTags {
+ tags[k] = v
+ }
data, _ := json.Marshal(tags)
f.Data = data
ffs.Touch(filePath, when...)
@@ -179,7 +180,9 @@ func Track(num int, title string, tags ...map[string]any) map[string]any {
ts["title"] = title
ts["track"] = num
for _, t := range tags {
- maps.Copy(ts, t)
+ for k, v := range t {
+ ts[k] = v
+ }
}
return ts
}
@@ -197,7 +200,9 @@ func MP3(tags ...map[string]any) *fstest.MapFile {
func File(tags ...map[string]any) *fstest.MapFile {
ts := map[string]any{}
for _, t := range tags {
- maps.Copy(ts, t)
+ for k, v := range t {
+ ts[k] = v
+ }
}
modTime := time.Now()
if mt, ok := ts[fakeFileInfoModTime]; !ok {
@@ -284,9 +289,6 @@ func (ffs *FakeFS) parseFile(filePath string) (*metadata.Info, error) {
p.AudioProperties.BitDepth = getInt("bitdepth")
p.AudioProperties.SampleRate = getInt("samplerate")
p.AudioProperties.Channels = getInt("channels")
- if codec, ok := data["codec"].(string); ok {
- p.AudioProperties.Codec = codec
- }
for k, v := range data {
p.Tags[k] = []string{fmt.Sprintf("%v", v)}
}
diff --git a/core/stream/aliases.go b/core/stream/aliases.go
deleted file mode 100644
index af42ac076..000000000
--- a/core/stream/aliases.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package stream
-
-import (
- "slices"
- "strings"
-)
-
-// containerAliasGroups maps each container alias to a canonical group name.
-var containerAliasGroups = func() map[string]string {
- groups := [][]string{
- {"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
- {"mpeg", "mp3", "mp2"},
- {"ogg", "oga", "opus"},
- {"aif", "aiff"},
- {"asf", "wma"},
- {"mpc", "mpp"},
- {"wv"},
- }
- m := make(map[string]string)
- for _, g := range groups {
- canonical := g[0]
- for _, name := range g {
- m[name] = canonical
- }
- }
- return m
-}()
-
-// codecAliasGroups maps each codec alias to a canonical group name.
-// Codecs within the same group are considered equivalent.
-var codecAliasGroups = func() map[string]string {
- groups := [][]string{
- {"aac", "adts"},
- {"ac3", "ac-3"},
- {"eac3", "e-ac3", "e-ac-3", "eac-3"},
- {"mpc7", "musepack7"},
- {"mpc8", "musepack8"},
- {"wma1", "wmav1"},
- {"wma2", "wmav2"},
- {"wmalossless", "wma9lossless"},
- {"wmapro", "wma9pro"},
- {"shn", "shorten"},
- {"mp4als", "als"},
- }
- m := make(map[string]string)
- for _, g := range groups {
- for _, name := range g {
- m[name] = g[0] // canonical = first entry
- }
- }
- return m
-}()
-
-// matchesWithAliases checks if a value matches any entry in candidates,
-// consulting the alias map for equivalent names.
-func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
- value = strings.ToLower(value)
- canonical := aliases[value]
- for _, c := range candidates {
- c = strings.ToLower(c)
- if c == value {
- return true
- }
- if canonical != "" && aliases[c] == canonical {
- return true
- }
- }
- return false
-}
-
-// matchesContainer checks if a file suffix matches any of the container names,
-// including common aliases.
-func matchesContainer(suffix string, containers []string) bool {
- return matchesWithAliases(suffix, containers, containerAliasGroups)
-}
-
-// matchesCodec checks if a codec matches any of the codec names,
-// including common aliases.
-func matchesCodec(codec string, codecs []string) bool {
- return matchesWithAliases(codec, codecs, codecAliasGroups)
-}
-
-// IsAACCodec returns true if the given codec or container name resolves to AAC.
-func IsAACCodec(name string) bool {
- return matchesCodec(name, []string{"aac"}) || matchesContainer(name, []string{"aac"})
-}
-
-func containsIgnoreCase(slice []string, s string) bool {
- return slices.ContainsFunc(slice, func(item string) bool {
- return strings.EqualFold(item, s)
- })
-}
diff --git a/core/stream/aliases_test.go b/core/stream/aliases_test.go
deleted file mode 100644
index 72f061810..000000000
--- a/core/stream/aliases_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package stream
-
-import (
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("Aliases", func() {
- Describe("IsAACCodec", func() {
- It("returns true for AAC and its aliases", func() {
- Expect(IsAACCodec("aac")).To(BeTrue())
- Expect(IsAACCodec("AAC")).To(BeTrue())
- Expect(IsAACCodec("adts")).To(BeTrue())
- Expect(IsAACCodec("m4a")).To(BeTrue())
- Expect(IsAACCodec("mp4")).To(BeTrue())
- Expect(IsAACCodec("m4b")).To(BeTrue())
- })
-
- It("returns false for non-AAC formats", func() {
- Expect(IsAACCodec("mp3")).To(BeFalse())
- Expect(IsAACCodec("opus")).To(BeFalse())
- Expect(IsAACCodec("flac")).To(BeFalse())
- Expect(IsAACCodec("ogg")).To(BeFalse())
- })
-
- It("returns false for empty string", func() {
- Expect(IsAACCodec("")).To(BeFalse())
- })
- })
-})
diff --git a/core/stream/codec.go b/core/stream/codec.go
deleted file mode 100644
index 56d163324..000000000
--- a/core/stream/codec.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package stream
-
-import "strings"
-
-// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal
-// codec names used throughout Navidrome (matching inferCodecFromSuffix output).
-// Most ffprobe names match directly; this handles the exceptions.
-func normalizeProbeCodec(codec string) string {
- c := strings.ToLower(codec)
- // DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf
- if strings.HasPrefix(c, "dsd") {
- return "dsd"
- }
- // PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc.
- if strings.HasPrefix(c, "pcm_") {
- return "pcm"
- }
- return c
-}
-
-// isLosslessFormat returns true if the format is a known lossless audio codec/format.
-// Detection is based on codec name only, not bit depth — some lossy codecs (e.g. ADPCM)
-// report non-zero bits_per_sample in ffprobe, so bit depth alone is not a reliable signal.
-//
-// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
-// ffmpeg can produce as output (a smaller set).
-func isLosslessFormat(format string) bool {
- switch strings.ToLower(format) {
- case "flac", "alac", "wav", "aiff", "ape", "wv", "wavpack", "tta", "tak", "shn", "dsd", "pcm":
- return true
- }
- return false
-}
-
-// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
-// it differently than PCM. Currently handles DSD (÷8):
-// DSD64=2822400→352800, DSD128=5644800→705600, etc.
-// For other codecs, returns the rate unchanged.
-func normalizeSourceSampleRate(sampleRate int, codec string) int {
- if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
- return sampleRate / 8
- }
- 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 {
- return 24
- }
- return srcBitDepth
-}
-
-// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
-// that always resample regardless of input (e.g., Opus always outputs 48000Hz).
-// Returns 0 if the codec has no fixed output rate.
-func codecFixedOutputSampleRate(codec string) int {
- switch strings.ToLower(codec) {
- case "opus":
- return 48000
- }
- return 0
-}
-
-// codecMaxSampleRate returns the hard maximum output sample rate for a codec.
-// Returns 0 if the codec has no hard limit.
-func codecMaxSampleRate(codec string) int {
- switch strings.ToLower(codec) {
- case "mp3":
- return 48000
- case "aac":
- return 96000
- }
- return 0
-}
-
-// codecMaxChannels returns the hard maximum number of audio channels a codec
-// supports. Returns 0 if the codec has no hard limit (or is unknown), in which
-// case the source/profile constraints applied upstream are authoritative.
-func codecMaxChannels(codec string) int {
- switch strings.ToLower(codec) {
- case "mp3":
- return 2
- case "opus":
- return 8
- }
- return 0
-}
diff --git a/core/stream/codec_test.go b/core/stream/codec_test.go
deleted file mode 100644
index 97e15bdb5..000000000
--- a/core/stream/codec_test.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package stream
-
-import (
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("Codec", func() {
- Describe("isLosslessFormat", func() {
- It("returns true for known lossless codecs", func() {
- Expect(isLosslessFormat("flac")).To(BeTrue())
- Expect(isLosslessFormat("alac")).To(BeTrue())
- Expect(isLosslessFormat("pcm")).To(BeTrue())
- Expect(isLosslessFormat("wav")).To(BeTrue())
- Expect(isLosslessFormat("dsd")).To(BeTrue())
- Expect(isLosslessFormat("ape")).To(BeTrue())
- Expect(isLosslessFormat("wv")).To(BeTrue())
- Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack
- })
-
- It("returns false for lossy codecs", func() {
- Expect(isLosslessFormat("mp3")).To(BeFalse())
- Expect(isLosslessFormat("aac")).To(BeFalse())
- Expect(isLosslessFormat("opus")).To(BeFalse())
- Expect(isLosslessFormat("vorbis")).To(BeFalse())
- })
-
- It("returns false for unknown codecs", func() {
- Expect(isLosslessFormat("unknown_codec")).To(BeFalse())
- })
-
- It("is case-insensitive", func() {
- Expect(isLosslessFormat("FLAC")).To(BeTrue())
- Expect(isLosslessFormat("Alac")).To(BeTrue())
- })
- })
-
- Describe("normalizeProbeCodec", func() {
- It("passes through common codec names unchanged", func() {
- Expect(normalizeProbeCodec("mp3")).To(Equal("mp3"))
- Expect(normalizeProbeCodec("aac")).To(Equal("aac"))
- Expect(normalizeProbeCodec("flac")).To(Equal("flac"))
- Expect(normalizeProbeCodec("opus")).To(Equal("opus"))
- Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis"))
- Expect(normalizeProbeCodec("alac")).To(Equal("alac"))
- Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2"))
- })
-
- It("normalizes DSD variants to dsd", func() {
- Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd"))
- Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd"))
- Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd"))
- Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd"))
- })
-
- It("normalizes PCM variants to pcm", func() {
- Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm"))
- Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm"))
- Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm"))
- Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm"))
- })
-
- It("lowercases input", func() {
- Expect(normalizeProbeCodec("MP3")).To(Equal("mp3"))
- Expect(normalizeProbeCodec("AAC")).To(Equal("aac"))
- Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd"))
- })
- })
-
- Describe("codecMaxChannels", func() {
- It("returns 2 for mp3", func() {
- Expect(codecMaxChannels("mp3")).To(Equal(2))
- })
-
- It("returns 8 for opus", func() {
- Expect(codecMaxChannels("opus")).To(Equal(8))
- })
-
- It("is case-insensitive", func() {
- Expect(codecMaxChannels("MP3")).To(Equal(2))
- Expect(codecMaxChannels("Opus")).To(Equal(8))
- })
-
- It("returns 0 for codecs with no hard limit", func() {
- Expect(codecMaxChannels("aac")).To(Equal(0))
- Expect(codecMaxChannels("flac")).To(Equal(0))
- Expect(codecMaxChannels("vorbis")).To(Equal(0))
- Expect(codecMaxChannels("")).To(Equal(0))
- })
- })
-})
diff --git a/core/stream/decider.go b/core/stream/decider.go
deleted file mode 100644
index 38839cdf0..000000000
--- a/core/stream/decider.go
+++ /dev/null
@@ -1,459 +0,0 @@
-package stream
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "strings"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/consts"
- "github.com/navidrome/navidrome/core/ffmpeg"
- "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
-
-// TranscodeDecider is the core service interface for making transcoding decisions
-type TranscodeDecider interface {
- MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error)
- 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 {
- return &deciderService{
- ds: ds,
- ff: ff,
- }
-}
-
-type deciderService struct {
- ds model.DataStore
- ff ffmpeg.FFmpeg
-}
-
-func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) {
- decision := &TranscodeDecision{
- MediaID: mf.ID,
- SourceUpdatedAt: mf.UpdatedAt,
- }
-
- var probe *ffmpeg.AudioProbeResult
- if !opts.SkipProbe {
- if !s.ff.IsProbeAvailable() {
- log.Debug(ctx, "ffprobe not available, using tag metadata for transcode decision", "mediaID", mf.ID)
- } else {
- var err error
- probe, err = s.ensureProbed(ctx, mf)
- if err != nil {
- return nil, err
- }
- }
- }
-
- // Build source stream details (uses probe data if available)
- decision.SourceStream = buildSourceStream(mf, probe)
- src := &decision.SourceStream
-
- log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
- "codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels,
- "sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name)
-
- // Check global bitrate constraint first.
- if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
- log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
- "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
- decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
- // Skip direct play profiles entirely — global constraint fails
- } else {
- // Try direct play profiles, collecting reasons for each failure
- for _, profile := range clientInfo.DirectPlayProfiles {
- if reason := s.checkDirectPlayProfile(src, &profile, clientInfo); reason == "" {
- decision.CanDirectPlay = true
- decision.TranscodeReasons = nil // Clear any previously collected reasons
- break
- } else {
- decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
- }
- }
- }
-
- // If direct play is possible, we're done
- if decision.CanDirectPlay {
- log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec)
- return decision, nil
- }
-
- // Try transcoding profiles (in order of preference)
- for _, profile := range clientInfo.TranscodingProfiles {
- if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &profile, clientInfo); ts != nil {
- decision.CanTranscode = true
- decision.TargetFormat = transcodeFormat
- decision.TargetBitrate = ts.Bitrate
- decision.TargetChannels = ts.Channels
- decision.TargetSampleRate = ts.SampleRate
- decision.TargetBitDepth = ts.BitDepth
- decision.TranscodeStream = ts
- break
- }
- }
-
- if decision.CanTranscode {
- log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
- "targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
- "targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
- }
-
- // If neither direct play nor transcode is possible
- if !decision.CanDirectPlay && !decision.CanTranscode {
- decision.ErrorReason = "no compatible playback profile found"
- log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
- "container", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons)
- }
-
- return decision, nil
-}
-
-func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Details {
- sd := Details{
- Container: mf.Suffix,
- Duration: mf.Duration,
- Size: mf.Size,
- }
-
- // Use pre-parsed probe result, or fall back to parsing stored probe data
- if probe == nil {
- probe, _ = parseProbeData(mf.ProbeData)
- }
-
- // Use probe data if available for authoritative values
- if probe != nil {
- sd.Codec = normalizeProbeCodec(probe.Codec)
- sd.Profile = probe.Profile
- sd.Bitrate = probe.BitRate
- sd.SampleRate = probe.SampleRate
- sd.BitDepth = probe.BitDepth
- sd.Channels = probe.Channels
- } else {
- sd.Codec = mf.AudioCodec()
- sd.Bitrate = mf.BitRate
- sd.SampleRate = mf.SampleRate
- sd.BitDepth = gg.V(mf.BitDepth)
- sd.Channels = mf.Channels
- }
- sd.IsLossless = isLosslessFormat(sd.Codec)
-
- return sd
-}
-
-// applyServerOverride replaces the client-provided profiles with synthetic ones
-// matching the server-forced transcoding format and bitrate.
-func applyServerOverride(ctx context.Context, original *ClientInfo, trc *model.Transcoding) *ClientInfo {
- maxBitRate := trc.DefaultBitRate
- if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
- maxBitRate = player.MaxBitRate
- }
-
- log.Debug(ctx, "Applying server-side transcoding override",
- "targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate,
- "client", original.Name)
-
- return &ClientInfo{
- Name: original.Name,
- Platform: original.Platform,
- MaxAudioBitrate: maxBitRate,
- MaxTranscodingAudioBitrate: maxBitRate,
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}},
- },
- TranscodingProfiles: []Profile{
- {Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP},
- },
- }
-}
-
-func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) {
- if data == "" {
- return nil, nil
- }
- var result ffmpeg.AudioProbeResult
- if err := json.Unmarshal([]byte(data), &result); err != nil {
- return nil, err
- }
- return &result, nil
-}
-
-// matchesPCMWAVBridge bridges Navidrome's internal "pcm" codec name with the
-// "wav" codec name that browsers use to advertise audio/wav support. The match
-// is scoped to WAV-container sources so AIFF files (which also normalize to
-// codec "pcm" but use a different container) cannot false-match a codec-only
-// ["wav"] profile.
-func matchesPCMWAVBridge(src *Details, profile *DirectPlayProfile) bool {
- return strings.EqualFold(src.Codec, "pcm") &&
- strings.EqualFold(src.Container, "wav") &&
- containsIgnoreCase(profile.AudioCodecs, "wav")
-}
-
-// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
-// or a typed reason string if it doesn't match.
-func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
- // Check protocol (only http for now)
- if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
- return "protocol not supported"
- }
-
- // Check container
- if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) {
- return fmt.Sprintf("container '%s' not supported by profile %s", src.Container, profile)
- }
-
- // Check codec
- if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) && !matchesPCMWAVBridge(src, profile) {
- return fmt.Sprintf("audio codec '%s' not supported by profile %s", src.Codec, profile)
- }
-
- // Check channels
- if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
- return fmt.Sprintf("audio channels %d not supported by profile %s (max %d)", src.Channels, profile, profile.MaxAudioChannels)
- }
-
- // Check codec-specific limitations
- for _, codecProfile := range clientInfo.CodecProfiles {
- if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) {
- if reason := checkLimitations(src, codecProfile.Limitations); reason != "" {
- return reason
- }
- }
- }
-
- return ""
-}
-
-// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
-// Returns the stream details and the internal transcoding format (which may differ from the
-// response container when a codec fallback occurs, e.g., "mp4"→"aac").
-// Returns nil, "" if the profile cannot produce a valid output.
-func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Details, profile *Profile, clientInfo *ClientInfo) (*Details, string) {
- // Check protocol (only http for now)
- if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
- log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
- return nil, ""
- }
-
- responseContainer, targetFormat := resolveTargetFormat(profile)
- if targetFormat == "" {
- return nil, ""
- }
-
- // Verify we have a transcoding command available (DB custom or built-in default)
- if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" {
- log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat)
- return nil, ""
- }
-
- targetIsLossless := isLosslessFormat(targetFormat)
-
- // Reject lossy to lossless conversion
- if !src.IsLossless && targetIsLossless {
- log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
- return nil, ""
- }
-
- ts := &Details{
- Container: responseContainer,
- Codec: strings.ToLower(profile.AudioCodec),
- SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec),
- Channels: src.Channels,
- BitDepth: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless),
- IsLossless: targetIsLossless,
- }
- if ts.Codec == "" {
- ts.Codec = targetFormat
- }
-
- // Apply codec-intrinsic sample rate adjustments before codec profile limitations
- if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
- ts.SampleRate = fixedRate
- }
- if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
- ts.SampleRate = maxRate
- }
- if maxCh := codecMaxChannels(ts.Codec); maxCh > 0 && ts.Channels > maxCh {
- ts.Channels = maxCh
- }
-
- // Determine target bitrate (all in kbps)
- if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
- return nil, ""
- }
-
- // Apply MaxAudioChannels from the transcoding profile. Compare against the
- // already-clamped ts.Channels (not src.Channels) so the codec hard limit
- // applied above is never raised by a looser profile setting.
- if profile.MaxAudioChannels > 0 && ts.Channels > profile.MaxAudioChannels {
- ts.Channels = profile.MaxAudioChannels
- }
-
- // Apply codec profile limitations to the TARGET codec
- if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
- return nil, ""
- }
-
- return ts, targetFormat
-}
-
-// lookupDefaultBitrate returns the default bitrate for the given format.
-// 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 {
- return t.DefaultBitRate
- }
- for _, dt := range consts.DefaultTranscodings {
- if dt.TargetFormat == format && dt.DefaultBitRate > 0 {
- return dt.DefaultBitRate
- }
- }
- return fallbackBitrate
-}
-
-// LookupTranscodeCommand returns the ffmpeg command for the given format.
-// 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)
- if err == nil && t.Command != "" {
- return t.Command
- }
- // Fall back to built-in defaults
- for _, dt := range consts.DefaultTranscodings {
- if dt.TargetFormat == format {
- return dt.Command
- }
- }
- return ""
-}
-
-// resolveTargetFormat determines the response container and internal target format
-// from the profile's Container and AudioCodec fields. When an AudioCodec is specified
-// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac").
-func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) {
- responseContainer = strings.ToLower(profile.Container)
- targetFormat = responseContainer
-
- // Prefer the audioCodec as targetFormat when provided (handles container-to-codec
- // mapping like "mp4" → "aac", "ogg" → "opus").
- if profile.AudioCodec != "" {
- targetFormat = strings.ToLower(profile.AudioCodec)
- }
-
- // If neither container nor audioCodec is set, we can't resolve a format.
- if targetFormat == "" {
- return "", ""
- }
-
- // When no container was specified, use the targetFormat as container too.
- if responseContainer == "" {
- responseContainer = targetFormat
- }
-
- return responseContainer, targetFormat
-}
-
-// computeBitrate determines the target bitrate for the transcoded stream.
-// Returns false if the profile should be rejected.
-func (s *deciderService) computeBitrate(ctx context.Context, src *Details, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
- if src.IsLossless {
- if !targetIsLossless {
- if clientInfo.MaxTranscodingAudioBitrate > 0 {
- ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
- } else if clientInfo.MaxAudioBitrate > 0 {
- ts.Bitrate = clientInfo.MaxAudioBitrate
- } else {
- ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat)
- }
- } else {
- if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
- log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
- "targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
- return false
- }
- }
- } else {
- ts.Bitrate = src.Bitrate
- }
-
- // Apply maxAudioBitrate as final cap
- if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
- ts.Bitrate = clientInfo.MaxAudioBitrate
- }
- return true
-}
-
-// applyCodecLimitations applies codec profile limitations to the transcoded stream.
-// Returns false if the profile should be rejected.
-func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
- targetCodec := ts.Codec
- for _, codecProfile := range clientInfo.CodecProfiles {
- if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
- continue
- }
- if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
- continue
- }
- for _, lim := range codecProfile.Limitations {
- result := applyLimitation(sourceBitrate, &lim, ts)
- if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
- log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
- "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
- return false
- }
- if result == adjustCannotFit {
- log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
- "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
- "comparison", lim.Comparison, "values", lim.Values)
- return false
- }
- }
- }
- return true
-}
-
-// ensureProbed runs ffprobe if probe data is missing, persists it, and returns
-// the parsed result. Returns (nil, nil) when probing is skipped or data already exists
-// (in which case the caller should parse mf.ProbeData).
-func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) (*ffmpeg.AudioProbeResult, error) {
- if mf.ProbeData != "" {
- return nil, nil
- }
- if !conf.Server.DevEnableMediaFileProbe {
- return nil, nil
- }
-
- result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath())
- if err != nil {
- return nil, fmt.Errorf("probing media file %s: %w", mf.ID, err)
- }
-
- data, err := json.Marshal(result)
- if err != nil {
- return nil, fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err)
- }
- mf.ProbeData = string(data)
-
- if err := s.ds.MediaFile().UpdateProbeData(ctx, 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
- }
-
- log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec,
- "profile", result.Profile, "bitRate", result.BitRate,
- "sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels)
- return result, nil
-}
diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go
deleted file mode 100644
index 01fef1249..000000000
--- a/core/stream/decider_test.go
+++ /dev/null
@@ -1,1295 +0,0 @@
-package stream
-
-import (
- "context"
- "encoding/json"
- "fmt"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
- "github.com/navidrome/navidrome/core/auth"
- "github.com/navidrome/navidrome/core/ffmpeg"
- "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"
-)
-
-// withProbe pre-populates ProbeData on a MediaFile from its own fields,
-// so ensureProbed short-circuits and tests don't need mock ffprobe results.
-func withProbe(mf *model.MediaFile) *model.MediaFile {
- probe := ffmpeg.AudioProbeResult{
- Codec: mf.AudioCodec(),
- BitRate: mf.BitRate,
- SampleRate: mf.SampleRate,
- BitDepth: gg.V(mf.BitDepth),
- Channels: mf.Channels,
- }
- data, _ := json.Marshal(probe)
- mf.ProbeData = string(data)
- return mf
-}
-
-var _ = Describe("Decider", func() {
- var (
- ds *tests.MockDataStore
- ff *tests.MockFFmpeg
- svc TranscodeDecider
- ctx context.Context
- )
-
- BeforeEach(func() {
- ctx = GinkgoT().Context()
- ds = &tests.MockDataStore{
- MockedProperty: &tests.MockedPropertyRepo{},
- MockedTranscoding: &tests.MockTranscodingRepo{},
- }
- ff = tests.NewMockFFmpeg("")
- auth.Init(ds)
- svc = NewTranscodeDecider(ds, ff)
- })
-
- Describe("MakeDecision", func() {
- Context("Direct Play", func() {
- It("allows direct play when profile matches", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- Expect(decision.CanTranscode).To(BeFalse())
- Expect(decision.TranscodeReasons).To(BeEmpty())
- })
-
- It("rejects direct play when container doesn't match", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.TranscodeReasons).To(ContainElement(And(
- ContainSubstring("container 'flac' not supported"),
- ContainSubstring("[mp3]"),
- )))
- })
-
- It("rejects direct play when codec doesn't match", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000, Channels: 2})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.TranscodeReasons).To(ContainElement(And(
- ContainSubstring("audio codec 'alac' not supported"),
- ContainSubstring("[m4a/aac]"),
- )))
- })
-
- It("rejects direct play when channels exceed limit", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.TranscodeReasons).To(ContainElement(And(
- ContainSubstring("audio channels 6 not supported"),
- ContainSubstring("[flac]"),
- ContainSubstring("(max 2)"),
- )))
- })
-
- It("accepts WAV source against a wav codec profile (pcm->wav bridge)", func() {
- // ffprobe normalizes PCM variants (pcm_s16le etc) to codec "pcm", but
- // browsers advertise WAV support as audioCodecs:["wav"] via audio/wav MIME.
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "wav", Codec: "pcm", BitRate: 1411, Channels: 2})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"wav"}, AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
-
- It("does not accept AIFF (pcm in non-wav container) against a wav codec profile", func() {
- // AIFF files also normalize to codec="pcm" but use container="aiff".
- // Without the container guard they would falsely match a codec-only
- // ["wav"] profile and be direct-played as if they were WAV.
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "aiff", Codec: "pcm", BitRate: 1411, Channels: 2})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}},
- },
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.TranscodeReasons).To(ContainElement(ContainSubstring("audio codec 'pcm'")))
- })
-
- It("handles container aliases (aac -> m4a)", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
-
- It("handles container aliases (mp4 -> m4a)", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
-
- It("handles container aliases (opus -> ogg)", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "opus", Codec: "Opus", BitRate: 165, Channels: 2, SampleRate: 48000})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
-
- It("handles codec aliases (adts -> aac)", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
-
- It("allows when protocol list is empty (any protocol)", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
-
- It("allows when both container and codec lists are empty (wildcard)", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{}, AudioCodecs: []string{}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
- })
-
- Context("MaxAudioBitrate constraint", func() {
- It("revokes direct play when bitrate exceeds maxAudioBitrate", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2})
- ci := &ClientInfo{
- MaxAudioBitrate: 500, // kbps
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- },
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
- })
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 256, // kbps
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
- },
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2},
- },
- }
- 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"))
- Expect(decision.TargetBitrate).To(Equal(256)) // kbps
- Expect(decision.TranscodeReasons).To(ContainElement(And(
- ContainSubstring("container 'flac' not supported"),
- ContainSubstring("[mp3]"),
- )))
- })
-
- It("rejects lossy to lossless transcoding", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2})
- ci := &ClientInfo{
- TranscodingProfiles: []Profile{
- {Container: "flac", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeFalse())
- })
-
- 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)})
- ci := &ClientInfo{
- TranscodingProfiles: []Profile{
- {Container: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetBitrate).To(Equal(160)) // mp3 default from mock transcoding repo
- })
-
- It("preserves lossy bitrate when under max", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 256, // kbps
- TranscodingProfiles: []Profile{
- {Container: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps
- })
-
- It("rejects format with no transcoding command available", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
- ci := &ClientInfo{
- TranscodingProfiles: []Profile{
- {Container: "wav", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeFalse())
- })
-
- It("applies maxAudioBitrate as final cap on transcoded stream", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2})
- ci := &ClientInfo{
- MaxAudioBitrate: 96, // kbps
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
- },
- TranscodingProfiles: []Profile{
- {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("opus"))
- })
- })
-
- 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)})
- ci := &ClientInfo{
- MaxAudioBitrate: 1000,
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- },
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("mp3"))
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy
- })
- })
-
- Context("No compatible profile", func() {
- It("returns error when nothing matches", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6})
- ci := &ClientInfo{}
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.CanTranscode).To(BeFalse())
- Expect(decision.ErrorReason).To(Equal("no compatible playback profile found"))
- })
- })
-
- Context("Codec limitations on direct play", func() {
- It("rejects direct play when codec limitation fails (required)", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "mp3",
- Limitations: []Limitation{
- {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
- })
-
- It("allows direct play when optional limitation fails", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "mp3",
- Limitations: []Limitation{
- {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: false},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
-
- It("handles Equals comparison with multiple values", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "flac",
- Limitations: []Limitation{
- {Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
-
- It("rejects when Equals comparison doesn't match any value", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "flac",
- Limitations: []Limitation{
- {Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- })
-
- It("rejects direct play when audioProfile limitation fails (required)", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "aac",
- Limitations: []Limitation{
- {Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: true},
- },
- },
- },
- }
- // Source profile is empty (not yet populated from scanner), so Equals("LC") fails
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported"))
- })
-
- It("allows direct play when audioProfile limitation is optional", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "aac",
- Limitations: []Limitation{
- {Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: false},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
-
- 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)})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "flac",
- Limitations: []Limitation{
- {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported"))
- })
- })
-
- Context("Codec limitations on transcoded output", func() {
- It("applies bitrate limitation to transcoded stream", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- MaxAudioBitrate: 96, // force transcode
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "mp3",
- Limitations: []Limitation{
- {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"96"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.Bitrate).To(Equal(96))
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "mp3",
- Limitations: []Limitation{
- {Name: LimitationAudioChannels, Comparison: ComparisonLessThanEqual, Values: []string{"2"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.Channels).To(Equal(2))
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "mp3",
- Limitations: []Limitation{
- {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
- })
-
- 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)})
- ci := &ClientInfo{
- TranscodingProfiles: []Profile{
- {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "flac",
- Limitations: []Limitation{
- {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
- Expect(decision.TargetBitDepth).To(Equal(16))
- })
-
- 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)})
- ci := &ClientInfo{
- TranscodingProfiles: []Profile{
- {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
- Expect(decision.TargetBitDepth).To(Equal(24))
- })
-
- It("omits bit depth when transcoding to a lossy format", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.BitDepth).To(BeZero())
- Expect(decision.TargetBitDepth).To(BeZero())
- })
-
- It("ignores audioBitdepth limitation when transcoding to a lossy format", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "opus",
- Limitations: []Limitation{
- {Name: LimitationAudioBitdepth, Comparison: ComparisonGreaterThanEqual, Values: []string{"32"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.BitDepth).To(BeZero())
- })
-
- It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "mp3",
- Limitations: []Limitation{
- {Name: LimitationAudioSamplerate, Comparison: ComparisonGreaterThanEqual, Values: []string{"96000"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeFalse())
- })
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("mp3"))
- // 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())
- })
-
- 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)})
- ci := &ClientInfo{
- TranscodingProfiles: []Profile{
- {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("flac"))
- // DSD64 2822400 / 8 = 352800, FLAC has no hard max
- Expect(decision.TranscodeStream.SampleRate).To(Equal(352800))
- Expect(decision.TargetSampleRate).To(Equal(352800))
- // DSD 1-bit → 24-bit PCM
- Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
- Expect(decision.TargetBitDepth).To(Equal(24))
- })
-
- 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)})
- ci := &ClientInfo{
- TranscodingProfiles: []Profile{
- {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "flac",
- Limitations: []Limitation{
- {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- // DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000
- Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
- Expect(decision.TargetSampleRate).To(Equal(48000))
- // DSD 1-bit → 24-bit PCM
- Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
- Expect(decision.TargetBitDepth).To(Equal(24))
- })
-
- 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)})
- ci := &ClientInfo{
- TranscodingProfiles: []Profile{
- {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
- },
- CodecProfiles: []CodecProfile{
- {
- Type: CodecProfileTypeAudio,
- Name: "flac",
- Limitations: []Limitation{
- {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true},
- },
- },
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- // DSD 1-bit → 24-bit PCM, then capped by codec profile limit to 16-bit
- Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
- Expect(decision.TargetBitDepth).To(Equal(16))
- })
- })
-
- Context("Codec channel limits", func() {
- It("clamps 6-channel FLAC to 2 channels when transcoding to MP3", 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("mp3"))
- Expect(decision.TranscodeStream.Channels).To(Equal(2))
- Expect(decision.TargetChannels).To(Equal(2))
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 1},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.Channels).To(Equal(1))
- Expect(decision.TargetChannels).To(Equal(1))
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 4},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.Channels).To(Equal(2))
- Expect(decision.TargetChannels).To(Equal(2))
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "m4a", AudioCodec: "aac", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("aac"))
- Expect(decision.TranscodeStream.Channels).To(Equal(6))
- Expect(decision.TargetChannels).To(Equal(6))
- })
- })
-
- 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)}
- probe := ffmpeg.AudioProbeResult{
- Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2,
- }
- data, _ := json.Marshal(probe)
- mf.ProbeData = string(data)
-
- ci := &ClientInfo{
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- MaxTranscodingAudioBitrate: 256,
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.SourceStream.IsLossless).To(BeTrue())
- Expect(decision.SourceStream.Codec).To(Equal("wavpack"))
- // Lossless source transcoding to MP3 should use MaxTranscodingAudioBitrate
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.Bitrate).To(Equal(256))
- })
-
- It("detects lossy from probe codec name", func() {
- mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2, SampleRate: 48000}
- probe := ffmpeg.AudioProbeResult{
- Codec: "vorbis", BitRate: 192, SampleRate: 48000, BitDepth: 0, Channels: 2,
- }
- data, _ := json.Marshal(probe)
- mf.ProbeData = string(data)
-
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"ogg"}, AudioCodecs: []string{"vorbis"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.SourceStream.IsLossless).To(BeFalse())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 128,
- 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.TargetFormat).To(Equal("opus"))
- // Opus always outputs 48000Hz
- Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
- Expect(decision.TargetSampleRate).To(Equal(48000))
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 128,
- 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.SampleRate).To(Equal(48000))
- })
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 256,
- TranscodingProfiles: []Profile{
- {Container: "mp4", AudioCodec: "aac", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- // TargetFormat is the internal format used for transcoding ("aac")
- Expect(decision.TargetFormat).To(Equal("aac"))
- // Container in the response preserves what the client asked ("mp4")
- Expect(decision.TranscodeStream.Container).To(Equal("mp4"))
- Expect(decision.TranscodeStream.Codec).To(Equal("aac"))
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 256,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetFormat).To(Equal("mp3"))
- Expect(decision.TranscodeStream.Container).To(Equal("mp3"))
- })
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TranscodeStream.SampleRate).To(Equal(44100))
- })
- })
-
- 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)})
- ci := &ClientInfo{
- MaxTranscodingAudioBitrate: 320,
- TranscodingProfiles: []Profile{
- {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- // DSD64 2822400 / 8 = 352800, capped by AAC max of 96000
- Expect(decision.TranscodeStream.SampleRate).To(Equal(96000))
- })
- })
-
- Context("Typed transcode reasons from multiple profiles", func() {
- It("collects reasons from each failed direct play profile", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 128, Channels: 2, SampleRate: 48000})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}},
- {Containers: []string{"m4a", "mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}},
- },
- TranscodingProfiles: []Profile{
- {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeFalse())
- Expect(decision.TranscodeReasons).To(HaveLen(3))
- Expect(decision.TranscodeReasons[0]).To(ContainSubstring("container 'ogg' not supported"))
- Expect(decision.TranscodeReasons[0]).To(ContainSubstring("[flac]"))
- Expect(decision.TranscodeReasons[1]).To(ContainSubstring("container 'ogg' not supported"))
- Expect(decision.TranscodeReasons[1]).To(ContainSubstring("[mp3/mp3]"))
- Expect(decision.TranscodeReasons[2]).To(ContainSubstring("container 'ogg' not supported"))
- Expect(decision.TranscodeReasons[2]).To(ContainSubstring("[m4a,mp4/aac]"))
- })
- })
-
- 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})
- ci := &ClientInfo{
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.SourceStream.Container).To(Equal("flac"))
- Expect(decision.SourceStream.Codec).To(Equal("flac"))
- Expect(decision.SourceStream.Bitrate).To(Equal(1000)) // kbps
- Expect(decision.SourceStream.SampleRate).To(Equal(96000))
- Expect(decision.SourceStream.BitDepth).To(Equal(24))
- Expect(decision.SourceStream.Channels).To(Equal(2))
- })
- })
-
- Context("Server-side context is ignored by MakeDecision", func() {
- It("ignores transcoding override in context", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
- ci := &ClientInfo{
- Name: "TestClient",
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
- decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
-
- 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)})
- ci := &ClientInfo{
- Name: "TestClient",
- DirectPlayProfiles: []DirectPlayProfile{
- {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
- },
- }
- playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
- decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanDirectPlay).To(BeTrue())
- })
- })
-
- 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)})
- ci := &ClientInfo{
- 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.TargetBitrate).To(Equal(96)) // opus default from mock
- })
-
- 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)})
- ci := &ClientInfo{
- TranscodingProfiles: []Profile{
- {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},
- },
- }
- decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
- Expect(err).ToNot(HaveOccurred())
- Expect(decision.CanTranscode).To(BeTrue())
- Expect(decision.TargetBitrate).To(Equal(256)) // aac default from mock
- })
-
- It("falls back to 256 for unknown format", func() {
- bitrate := lookupDefaultBitrate(ctx, ds, "xyz")
- Expect(bitrate).To(Equal(fallbackBitrate))
- })
- })
-
- 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() {
- var mockMFRepo *tests.MockMediaFileRepo
-
- BeforeEach(func() {
- mockMFRepo = tests.CreateMockMediaFileRepo()
- ds.MockedMediaFile = mockMFRepo
- })
-
- It("calls ffprobe and populates ProbeData when empty", func() {
- mf := &model.MediaFile{ID: "probe-1", Suffix: "mp3", BitRate: 320, Channels: 2}
- mockMFRepo.SetData(model.MediaFiles{*mf})
-
- ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{
- Codec: "mp3", BitRate: 320, SampleRate: 44100, Channels: 2,
- }
-
- svc := NewTranscodeDecider(ds, ff).(*deciderService)
- probe, err := svc.ensureProbed(ctx, mf)
- Expect(err).ToNot(HaveOccurred())
- Expect(mf.ProbeData).ToNot(BeEmpty())
- Expect(probe).ToNot(BeNil())
- Expect(probe.Codec).To(Equal("mp3"))
- Expect(probe.BitRate).To(Equal(320))
- Expect(probe.SampleRate).To(Equal(44100))
- Expect(probe.Channels).To(Equal(2))
-
- // Verify persisted to DB
- stored := mockMFRepo.Data["probe-1"]
- Expect(stored.ProbeData).To(Equal(mf.ProbeData))
- })
-
- It("skips ffprobe when ProbeData is already set", func() {
- mf := withProbe(&model.MediaFile{ID: "probe-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2})
-
- // Set error on mock — if ffprobe were called, this would fail
- ff.Error = fmt.Errorf("should not be called")
-
- svc := NewTranscodeDecider(ds, ff).(*deciderService)
- probe, err := svc.ensureProbed(ctx, mf)
- Expect(err).ToNot(HaveOccurred())
- Expect(probe).To(BeNil())
- })
-
- It("returns error when ffprobe fails", func() {
- mf := &model.MediaFile{ID: "probe-3", Suffix: "mp3"}
- ff.Error = fmt.Errorf("ffprobe not found")
-
- svc := NewTranscodeDecider(ds, ff).(*deciderService)
- _, err := svc.ensureProbed(ctx, mf)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("probing media file"))
- Expect(mf.ProbeData).To(BeEmpty())
- })
-
- It("skips ffprobe when DevEnableMediaFileProbe is false", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.DevEnableMediaFileProbe = false
-
- mf := &model.MediaFile{ID: "probe-4", Suffix: "mp3"}
- // Set a result — if ffprobe were called, ProbeData would be populated
- ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{Codec: "mp3"}
-
- svc := NewTranscodeDecider(ds, ff).(*deciderService)
- probe, err := svc.ensureProbed(ctx, mf)
- Expect(err).ToNot(HaveOccurred())
- Expect(probe).To(BeNil())
- Expect(mf.ProbeData).To(BeEmpty())
- })
- })
-
-})
diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go
deleted file mode 100644
index 5b732c3e2..000000000
--- a/core/stream/legacy_client.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package stream
-
-import (
- "context"
- "strings"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
-)
-
-// 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 {
- ci := &ClientInfo{Name: "legacy"}
-
- // Determine target format for transcoding
- var targetFormat string
- switch {
- case reqFormat != "":
- 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 != "" {
- // Add a direct play profile for the source format when no explicit
- // format was requested (bitrate-only downsampling) or when the
- // requested format matches the source. When the client explicitly
- // requests a different format, direct play must not match the
- // source — otherwise the source is returned untranscoded.
- if reqFormat == "" || strings.EqualFold(reqFormat, mf.Suffix) {
- ci.DirectPlayProfiles = []DirectPlayProfile{
- {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}},
- }
- }
- ci.TranscodingProfiles = []Profile{
- {Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP},
- }
- if reqBitRate > 0 {
- ci.MaxAudioBitrate = reqBitRate
- ci.MaxTranscodingAudioBitrate = reqBitRate
- }
- } else {
- // No transcoding requested — direct play everything
- ci.DirectPlayProfiles = []DirectPlayProfile{
- {Protocols: []string{ProtocolHTTP}},
- }
- }
-
- return ci
-}
-
-// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters
-// into a fully specified Request.
-func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request {
- var req Request
- req.Offset = offset
-
- if reqFormat == "raw" {
- req.Format = "raw"
- return req
- }
-
- playerMaxBitRate := 0
- if player, ok := request.PlayerFrom(ctx); ok {
- playerMaxBitRate = player.MaxBitRate
- }
-
- clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate)
- if resolved, ok := s.resolve(ctx, mf, clientInfo, offset); ok {
- return resolved
- }
-
- // No compatible profile for the requested format — retry with DefaultDownsamplingFormat
- // TODO: validate DefaultDownsamplingFormat at startup to warn about unsupported values
- fallbackFormat := conf.Server.DefaultDownsamplingFormat
- if reqFormat != "" && fallbackFormat != "" && !strings.EqualFold(reqFormat, fallbackFormat) {
- log.Warn(ctx, "Requested format not available, falling back to default downsampling format",
- "requestedFormat", reqFormat, "fallbackFormat", fallbackFormat, "id", mf.ID)
- return s.ResolveRequest(ctx, mf, fallbackFormat, reqBitRate, offset)
- }
-
- // Ultimate fallback — raw
- 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
deleted file mode 100644
index ad3417faf..000000000
--- a/core/stream/legacy_client_test.go
+++ /dev/null
@@ -1,485 +0,0 @@
-package stream
-
-import (
- "context"
-
- "github.com/navidrome/navidrome/conf"
- "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"
-)
-
-var _ = Describe("buildLegacyClientInfo", func() {
- var mf *model.MediaFile
-
- BeforeEach(func() {
- mf = &model.MediaFile{Suffix: "flac", BitRate: 960}
- })
-
- It("sets transcoding profile for explicit format without bitrate", func() {
- ci := buildLegacyClientInfo(mf, "mp3", 0, 0)
-
- Expect(ci.Name).To(Equal("legacy"))
- Expect(ci.TranscodingProfiles).To(HaveLen(1))
- Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
- Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
- Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
- Expect(ci.MaxAudioBitrate).To(BeZero())
- Expect(ci.MaxTranscodingAudioBitrate).To(BeZero())
- Expect(ci.DirectPlayProfiles).To(BeEmpty())
- })
-
- It("does not add direct play profile when explicit format differs from source (no bitrate)", func() {
- ci := buildLegacyClientInfo(mf, "opus", 0, 0)
-
- Expect(ci.TranscodingProfiles).To(HaveLen(1))
- Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
- Expect(ci.DirectPlayProfiles).To(BeEmpty())
- })
-
- It("adds direct play profile when explicit format matches source format", func() {
- ci := buildLegacyClientInfo(mf, "flac", 0, 0)
-
- Expect(ci.TranscodingProfiles).To(HaveLen(1))
- Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac"))
- Expect(ci.DirectPlayProfiles).To(HaveLen(1))
- Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
- Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
- })
-
- It("sets transcoding profile and bitrate for explicit format with bitrate", func() {
- ci := buildLegacyClientInfo(mf, "mp3", 192, 0)
-
- Expect(ci.TranscodingProfiles).To(HaveLen(1))
- Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
- Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
- Expect(ci.MaxAudioBitrate).To(Equal(192))
- Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192))
- Expect(ci.DirectPlayProfiles).To(BeEmpty())
- })
-
- It("returns direct play profile when no format and no bitrate", func() {
- ci := buildLegacyClientInfo(mf, "", 0, 0)
-
- Expect(ci.DirectPlayProfiles).To(HaveLen(1))
- Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
- Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
- Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
- Expect(ci.TranscodingProfiles).To(BeEmpty())
- Expect(ci.MaxAudioBitrate).To(BeZero())
- })
-
- It("uses default downsampling format for bitrate-only downsampling", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.DefaultDownsamplingFormat = "opus"
-
- ci := buildLegacyClientInfo(mf, "", 128, 0)
-
- Expect(ci.TranscodingProfiles).To(HaveLen(1))
- Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
- Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
- Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
- Expect(ci.MaxAudioBitrate).To(Equal(128))
- Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128))
- Expect(ci.DirectPlayProfiles).To(HaveLen(1))
- Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
- Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
- })
-
- It("returns direct play when bitrate >= source bitrate", func() {
- ci := buildLegacyClientInfo(mf, "", 960, 0)
-
- Expect(ci.DirectPlayProfiles).To(HaveLen(1))
- Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
- Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
- Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
- 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() {
- var (
- svc TranscodeDecider
- ctx context.Context
- )
-
- BeforeEach(func() {
- ctx = GinkgoT().Context()
- ds := &tests.MockDataStore{
- MockedProperty: &tests.MockedPropertyRepo{},
- MockedTranscoding: &tests.MockTranscodingRepo{},
- }
- ff := tests.NewMockFFmpeg("")
- auth.Init(ds)
- svc = NewTranscodeDecider(ds, ff)
- })
-
- It("returns raw when format is 'raw'", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "raw", 0, 0)
-
- Expect(req.Format).To(Equal("raw"))
- })
-
- It("returns raw (direct play) when no format or bitrate specified", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "", 0, 0)
-
- Expect(req.Format).To(Equal("raw"))
- })
-
- 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)})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "opus", 0, 0)
-
- Expect(req.Format).To(Equal("opus"))
- })
-
- 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)})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0)
-
- Expect(req.Format).To(Equal("mp3"))
- Expect(req.BitRate).To(Equal(128))
- })
-
- It("returns raw when requested format matches source and no bitrate reduction", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "mp3", 320, 0)
-
- Expect(req.Format).To(Equal("raw"))
- })
-
- It("downsamples when only bitrate is specified below source", 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)})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "", 128, 0)
-
- Expect(req.Format).To(Equal("opus"))
- Expect(req.BitRate).To(Equal(128))
- })
-
- It("passes offset through", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "opus", 128, 30)
-
- Expect(req.Format).To(Equal("opus"))
- Expect(req.Offset).To(Equal(30))
- })
-
- Context("Server-side player transcoding override", func() {
- It("forces transcoding when override targets a different format", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
- overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
- overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
-
- Expect(req.Format).To(Equal("mp3"))
- Expect(req.BitRate).To(Equal(192))
- })
-
- It("allows direct play when source matches forced format and bitrate is within cap", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
- overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
-
- Expect(req.Format).To(Equal("raw"))
- })
-
- It("transcodes when source bitrate exceeds the forced cap", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
- overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
-
- Expect(req.Format).To(Equal("mp3"))
- Expect(req.BitRate).To(Equal(192))
- })
-
- It("uses player MaxBitRate over transcoding DefaultBitRate", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
- overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192})
- overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
-
- Expect(req.Format).To(Equal("mp3"))
- Expect(req.BitRate).To(Equal(320))
- })
-
- It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
- overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0})
- overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0)
-
- Expect(req.Format).To(Equal("mp3"))
- // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock)
- Expect(req.BitRate).To(Equal(160))
- })
-
- It("does not apply override when no transcoding is in context", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "", 0, 0)
-
- Expect(req.Format).To(Equal("raw"))
- })
- })
-
- 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)})
- playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(playerCtx, mf, "mp3", 0, 0)
-
- Expect(req.Format).To(Equal("mp3"))
- Expect(req.BitRate).To(Equal(320))
- })
-
- 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)})
- playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(playerCtx, mf, "mp3", 256, 0)
-
- Expect(req.Format).To(Equal("mp3"))
- Expect(req.BitRate).To(Equal(256))
- })
-
- It("does not cap when player MaxBitRate is 0", func() {
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
- playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
-
- 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() {
- It("falls back to DefaultDownsamplingFormat", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.DefaultDownsamplingFormat = "opus"
-
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
-
- Expect(req.Format).To(Equal("opus"))
- })
-
- It("falls back to raw when DefaultDownsamplingFormat is empty", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.DefaultDownsamplingFormat = ""
-
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
-
- Expect(req.Format).To(Equal("raw"))
- })
-
- It("falls back to raw when DefaultDownsamplingFormat is also invalid", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.DefaultDownsamplingFormat = "xyz"
-
- mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0)
-
- Expect(req.Format).To(Equal("raw"))
- })
-
- It("preserves bitrate when falling back to DefaultDownsamplingFormat", 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)})
-
- decider := svc.(*deciderService)
- req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0)
-
- Expect(req.Format).To(Equal("opus"))
- Expect(req.BitRate).To(Equal(128))
- })
- })
-})
-
-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/limitations.go b/core/stream/limitations.go
deleted file mode 100644
index ab70d6c07..000000000
--- a/core/stream/limitations.go
+++ /dev/null
@@ -1,171 +0,0 @@
-package stream
-
-import (
- "strconv"
- "strings"
-)
-
-// adjustResult represents the outcome of applying a limitation to a transcoded stream value
-type adjustResult int
-
-const (
- adjustNone adjustResult = iota // Value already satisfies the limitation
- adjustAdjusted // Value was changed to fit the limitation
- adjustCannotFit // Cannot satisfy the limitation (reject this profile)
-)
-
-// checkLimitations checks codec profile limitations against source stream details.
-// Returns "" if all limitations pass, or a typed reason string for the first failure.
-func checkLimitations(src *Details, limitations []Limitation) string {
- for _, lim := range limitations {
- var ok bool
- var reason string
-
- switch lim.Name {
- case LimitationAudioChannels:
- ok = checkIntLimitation(src.Channels, lim.Comparison, lim.Values)
- reason = "audio channels not supported"
- case LimitationAudioSamplerate:
- ok = checkIntLimitation(src.SampleRate, lim.Comparison, lim.Values)
- reason = "audio samplerate not supported"
- case LimitationAudioBitrate:
- ok = checkIntLimitation(src.Bitrate, lim.Comparison, lim.Values)
- reason = "audio bitrate not supported"
- case LimitationAudioBitdepth:
- ok = checkIntLimitation(src.BitDepth, lim.Comparison, lim.Values)
- reason = "audio bitdepth not supported"
- case LimitationAudioProfile:
- ok = checkStringLimitation(src.Profile, lim.Comparison, lim.Values)
- reason = "audio profile not supported"
- default:
- continue
- }
-
- if !ok && lim.Required {
- return reason
- }
- }
- return ""
-}
-
-// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
-// Returns the adjustment result.
-func applyLimitation(sourceBitrate int, lim *Limitation, ts *Details) adjustResult {
- switch lim.Name {
- case LimitationAudioChannels:
- return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
- case LimitationAudioBitrate:
- current := ts.Bitrate
- if current == 0 {
- current = sourceBitrate
- }
- return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
- case LimitationAudioSamplerate:
- return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
- case LimitationAudioBitdepth:
- if ts.BitDepth > 0 {
- return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
- }
- case LimitationAudioProfile:
- // TODO: implement when audio profile data is available
- }
- return adjustNone
-}
-
-// applyIntLimitation applies a limitation comparison to a value.
-// If the value needs adjusting, calls the setter and returns the result.
-func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
- if len(values) == 0 {
- return adjustNone
- }
-
- switch comparison {
- case ComparisonLessThanEqual:
- limit, ok := parseInt(values[0])
- if !ok {
- return adjustNone
- }
- if current <= limit {
- return adjustNone
- }
- setter(limit)
- return adjustAdjusted
- case ComparisonGreaterThanEqual:
- limit, ok := parseInt(values[0])
- if !ok {
- return adjustNone
- }
- if current >= limit {
- return adjustNone
- }
- // Cannot upscale
- return adjustCannotFit
- case ComparisonEquals:
- // Check if current value matches any allowed value
- for _, v := range values {
- if limit, ok := parseInt(v); ok && current == limit {
- return adjustNone
- }
- }
- // Find the closest allowed value below current (don't upscale)
- var closest int
- found := false
- for _, v := range values {
- if limit, ok := parseInt(v); ok && limit < current {
- if !found || limit > closest {
- closest = limit
- found = true
- }
- }
- }
- if found {
- setter(closest)
- return adjustAdjusted
- }
- return adjustCannotFit
- case ComparisonNotEquals:
- for _, v := range values {
- if limit, ok := parseInt(v); ok && current == limit {
- return adjustCannotFit
- }
- }
- return adjustNone
- }
-
- return adjustNone
-}
-
-func checkIntLimitation(value int, comparison string, values []string) bool {
- return applyIntLimitation(comparison, values, value, func(int) {}) == adjustNone
-}
-
-// checkStringLimitation checks a string value against a limitation.
-// Only Equals and NotEquals comparisons are meaningful for strings.
-// LessThanEqual/GreaterThanEqual are not applicable and always pass.
-func checkStringLimitation(value string, comparison string, values []string) bool {
- switch comparison {
- case ComparisonEquals:
- for _, v := range values {
- if strings.EqualFold(value, v) {
- return true
- }
- }
- return false
- case ComparisonNotEquals:
- for _, v := range values {
- if strings.EqualFold(value, v) {
- return false
- }
- }
- return true
- }
- return true
-}
-
-func parseInt(s string) (int, bool) {
- v, err := strconv.Atoi(s)
- if err != nil || v < 0 {
- return 0, false
- }
- return v, true
-}
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
deleted file mode 100644
index c250b0c4e..000000000
--- a/core/stream/media_streamer.go
+++ /dev/null
@@ -1,305 +0,0 @@
-package stream
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "mime"
- "net/http"
- "os"
- "strconv"
- "sync"
- "time"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/consts"
- "github.com/navidrome/navidrome/core/ffmpeg"
- "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/req"
-)
-
-type MediaStreamer interface {
- NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error)
-}
-
-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),
- }
-}
-
-type mediaStreamer struct {
- ds model.DataStore
- transcoder ffmpeg.FFmpeg
- cache cache.FileCache
- limiter TranscodeLimiter
-}
-
-type streamJob struct {
- ms *mediaStreamer
- mf *model.MediaFile
- filePath string
- format string
- bitRate int
- sampleRate int
- bitDepth int
- channels int
- offset int
-}
-
-func (j *streamJob) Key() string {
- return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
-}
-
-// NewStream creates a Stream for the given MediaFile and Request. It handles both raw streaming (no transcoding)
-// and transcoded streaming based on the requested format and bitrate. It also logs detailed information about
-// the streaming request and whether the transcoding result was served from cache or not.
-func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) {
- var format string
- var bitRate int
- var cached bool
- defer func() {
- log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
- "bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
- "user", userName(ctx), "transcoding", format != "raw",
- "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
- }()
-
- format = req.Format
- bitRate = req.BitRate
- if format == "" || format == "raw" {
- format = "raw"
- bitRate = 0
- }
- s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
- filePath := mf.AbsolutePath()
-
- if format == "raw" {
- log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
- "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
- "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
- "selectedBitrate", bitRate, "selectedFormat", format)
- f, err := os.Open(filePath)
- if err != nil {
- return nil, err
- }
- s.ReadCloser = f
- s.Seeker = f
- s.format = mf.Suffix
- return s, nil
- }
-
- job := &streamJob{
- ms: ms,
- mf: mf,
- filePath: filePath,
- format: format,
- bitRate: bitRate,
- sampleRate: req.SampleRate,
- bitDepth: req.BitDepth,
- channels: req.Channels,
- offset: req.Offset,
- }
- 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)
- }
- return nil, err
- }
- cached = r.Cached
-
- s.ReadCloser = r
- s.Seeker = r.Seeker
-
- log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
- "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
- "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
- "selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
-
- return s, nil
-}
-
-type Stream struct {
- ctx context.Context //nolint:containedctx // stream outlives the call that built it; Read has no ctx
- mf *model.MediaFile
- bitRate int
- format string
- io.ReadCloser
- io.Seeker
-}
-
-func (s *Stream) Seekable() bool { return s.Seeker != nil }
-func (s *Stream) Duration() float32 { return s.mf.Duration }
-func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
-func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
-func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
-func (s *Stream) EstimatedContentLength() int {
- return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
-}
-
-// 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
-// (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() {
- http.ServeContent(w, r, s.Name(), s.ModTime(), s)
- return -1, nil
- }
-
- w.Header().Set("Accept-Ranges", "none")
- w.Header().Set("Content-Type", s.ContentType())
-
- if req.Params(r).BoolOr("estimateContentLength", false) {
- length := strconv.Itoa(s.EstimatedContentLength())
- log.Trace(ctx, "Estimated content-length", "contentLength", length)
- w.Header().Set("Content-Length", length)
- }
-
- if r.Method == http.MethodHead {
- go func() { _, _ = io.Copy(io.Discard, s) }()
- return 0, nil
- }
-
- id := s.mf.ID
- c, err := io.Copy(w, s)
- if err != nil {
- log.Error(ctx, "Error sending transcoded file", "id", id, err)
- if c == 0 {
- 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)
- }
- if c == 0 {
- log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+
- "Check that ffmpeg supports the requested codec. Enable Trace logging for ffmpeg stderr details",
- "id", id, "format", s.ContentType())
- } else {
- log.Trace(ctx, "Success sending transcoded file", "id", id, "size", c)
- }
- return c, nil
-}
-
-// NewStream creates a non-seekable Stream from the given components.
-func NewStream(mf *model.MediaFile, format string, bitRate int, r io.ReadCloser) *Stream {
- return &Stream{
- ctx: context.Background(),
- mf: mf,
- format: format,
- bitRate: bitRate,
- ReadCloser: r,
- }
-}
-
-var (
- onceTranscodingCache sync.Once
- instanceTranscodingCache TranscodingCache
-)
-
-func GetTranscodingCache() TranscodingCache {
- onceTranscodingCache.Do(func() {
- instanceTranscodingCache = NewTranscodingCache()
- })
- return instanceTranscodingCache
-}
-
-func NewTranscodingCache() TranscodingCache {
- return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
- consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
- func(ctx context.Context, arg cache.Item) (io.Reader, error) {
- job := arg.(*streamJob)
- command := LookupTranscodeCommand(ctx, job.ms.ds, job.format)
- if command == "" {
- log.Error(ctx, "No transcoding command available", "format", job.format)
- 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.
- var transcodingCtx context.Context
- if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation {
- transcodingCtx = ctx
- } else {
- transcodingCtx = request.AddValues(context.Background(), ctx)
- }
-
- out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{
- Command: command,
- Format: job.format,
- FilePath: job.filePath,
- BitRate: job.bitRate,
- SampleRate: job.sampleRate,
- 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
- })
-}
-
-// userName extracts the username from the context for logging purposes.
-func userName(ctx context.Context) string {
- if user, ok := request.UserFrom(ctx); !ok {
- return "UNKNOWN"
- } else {
- 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
deleted file mode 100644
index 5a4bcd480..000000000
--- a/core/stream/media_streamer_test.go
+++ /dev/null
@@ -1,194 +0,0 @@
-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"
-)
-
-var _ = Describe("MediaStreamer", func() {
- var streamer stream.MediaStreamer
- var ds model.DataStore
- ffmpeg := tests.NewMockFFmpeg("fake data")
- ctx := log.NewContext(context.TODO())
-
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- cacheDir, _ := os.MkdirTemp("", "file_caches")
- conf.Server.CacheFolder = conf.NewDir(cacheDir)
- conf.Server.TranscodingCacheSize = "100MB"
- ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
- ds.MediaFile().(*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())
- streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache)
- })
- AfterEach(func() {
- _ = os.RemoveAll(conf.Server.CacheFolder.String())
- })
-
- Context("NewStream", func() {
- var mf *model.MediaFile
- BeforeEach(func() {
- var err error
- mf, err = ds.MediaFile().Get(ctx, "123")
- Expect(err).ToNot(HaveOccurred())
- })
- It("returns a seekable stream if format is 'raw'", func() {
- s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "raw"})
- Expect(err).ToNot(HaveOccurred())
- Expect(s.Seekable()).To(BeTrue())
- })
- It("returns a seekable stream if no format is specified (direct play)", func() {
- s, err := streamer.NewStream(ctx, mf, stream.Request{})
- Expect(err).ToNot(HaveOccurred())
- Expect(s.Seekable()).To(BeTrue())
- })
- It("returns a NON seekable stream if transcode is required", func() {
- s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 64})
- Expect(err).To(BeNil())
- 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())
- _, _ = io.ReadAll(s)
- _ = s.Close()
- Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
-
- s, err = streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
- Expect(err).To(BeNil())
- 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/stream_suite_test.go b/core/stream/stream_suite_test.go
deleted file mode 100644
index 36e9e7f43..000000000
--- a/core/stream/stream_suite_test.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package stream
-
-import (
- "testing"
-
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-func TestStream(t *testing.T) {
- tests.Init(t, false)
- log.SetLevel(log.LevelFatal)
- RegisterFailHandler(Fail)
- RunSpecs(t, "Stream Suite")
-}
diff --git a/core/stream/token.go b/core/stream/token.go
deleted file mode 100644
index 21a26ca93..000000000
--- a/core/stream/token.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package stream
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- "github.com/lestrrat-go/jwx/v3/jwt"
- "github.com/navidrome/navidrome/core/auth"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
-)
-
-const tokenTTL = 48 * time.Hour
-
-// params contains the parameters extracted from a transcode token.
-// TargetBitrate is in kilobits per second (kbps).
-type params struct {
- MediaID string
- DirectPlay bool
- TargetFormat string
- TargetBitrate int
- TargetChannels int
- TargetSampleRate int
- TargetBitDepth int
- SourceUpdatedAt time.Time
-}
-
-// toClaimsMap converts a Decision into a JWT claims map for token encoding.
-// Only non-zero transcode fields are included.
-func (d *TranscodeDecision) toClaimsMap() map[string]any {
- m := map[string]any{
- "mid": d.MediaID,
- "ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(),
- jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(),
- }
- if d.CanDirectPlay {
- m["dp"] = true
- }
- if d.CanTranscode && d.TargetFormat != "" {
- m["f"] = d.TargetFormat
- if d.TargetBitrate != 0 {
- m["b"] = d.TargetBitrate
- }
- if d.TargetChannels != 0 {
- m["ch"] = d.TargetChannels
- }
- if d.TargetSampleRate != 0 {
- m["sr"] = d.TargetSampleRate
- }
- if d.TargetBitDepth != 0 {
- m["bd"] = d.TargetBitDepth
- }
- }
- return m
-}
-
-// paramsFromToken extracts and validates Params from a parsed JWT token.
-// Returns an error if required claims (media ID, source timestamp) are missing.
-func paramsFromToken(token jwt.Token) (*params, error) {
- var p params
- var mid string
- if err := token.Get("mid", &mid); err == nil {
- p.MediaID = mid
- }
- if p.MediaID == "" {
- return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid)
- }
-
- var dp bool
- if err := token.Get("dp", &dp); err == nil {
- p.DirectPlay = dp
- }
-
- ua := getIntClaim(token, "ua")
- if ua != 0 {
- p.SourceUpdatedAt = time.Unix(int64(ua), 0)
- }
- if p.SourceUpdatedAt.IsZero() {
- return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid)
- }
-
- var f string
- if err := token.Get("f", &f); err == nil {
- p.TargetFormat = f
- }
- p.TargetBitrate = getIntClaim(token, "b")
- p.TargetChannels = getIntClaim(token, "ch")
- p.TargetSampleRate = getIntClaim(token, "sr")
- p.TargetBitDepth = getIntClaim(token, "bd")
- 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.
-func getIntClaim(token jwt.Token, key string) int {
- var f float64
- if err := token.Get(key, &f); err == nil {
- return int(f)
- }
- return 0
-}
-
-func (s *deciderService) CreateTranscodeParams(decision *TranscodeDecision) (string, error) {
- return auth.EncodeToken(decision.toClaimsMap())
-}
-
-func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) {
- token, err := auth.DecodeAndVerifyToken(tokenStr)
- if err != nil {
- return nil, err
- }
- return paramsFromToken(token)
-}
-
-func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) {
- p, err := s.parseTranscodeParams(token)
- if err != nil {
- return Request{}, errors.Join(ErrTokenInvalid, err)
- }
- if p.MediaID != mf.ID {
- return Request{}, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mf.ID)
- }
- if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) {
- log.Info(ctx, "Transcode token is stale", "mediaID", mf.ID,
- "tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
- return Request{}, ErrTokenStale
- }
-
- req := Request{Offset: offset}
- if !p.DirectPlay && p.TargetFormat != "" {
- req.Format = p.TargetFormat
- req.BitRate = p.TargetBitrate
- req.SampleRate = p.TargetSampleRate
- req.BitDepth = p.TargetBitDepth
- req.Channels = p.TargetChannels
- }
- return req, nil
-}
diff --git a/core/stream/token_test.go b/core/stream/token_test.go
deleted file mode 100644
index 4f0d8066c..000000000
--- a/core/stream/token_test.go
+++ /dev/null
@@ -1,266 +0,0 @@
-package stream
-
-import (
- "context"
- "time"
-
- "github.com/go-chi/jwtauth/v5"
- "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("Token", func() {
- var (
- ds *tests.MockDataStore
- ff *tests.MockFFmpeg
- svc TranscodeDecider
- ctx context.Context
- )
-
- BeforeEach(func() {
- ctx = GinkgoT().Context()
- ds = &tests.MockDataStore{
- MockedProperty: &tests.MockedPropertyRepo{},
- MockedTranscoding: &tests.MockTranscodingRepo{},
- }
- ff = tests.NewMockFFmpeg("")
- auth.Init(ds)
- svc = NewTranscodeDecider(ds, ff)
- })
-
- Describe("Token round-trip", func() {
- var (
- sourceTime time.Time
- impl *deciderService
- )
-
- BeforeEach(func() {
- sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
- impl = svc.(*deciderService)
- })
-
- It("creates and parses a direct play token", func() {
- decision := &TranscodeDecision{
- MediaID: "media-123",
- CanDirectPlay: true,
- SourceUpdatedAt: sourceTime,
- }
- token, err := svc.CreateTranscodeParams(decision)
- Expect(err).ToNot(HaveOccurred())
- Expect(token).ToNot(BeEmpty())
-
- params, err := impl.parseTranscodeParams(token)
- Expect(err).ToNot(HaveOccurred())
- Expect(params.MediaID).To(Equal("media-123"))
- Expect(params.DirectPlay).To(BeTrue())
- Expect(params.TargetFormat).To(BeEmpty())
- Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix()))
- })
-
- It("creates and parses a transcode token with kbps bitrate", func() {
- decision := &TranscodeDecision{
- MediaID: "media-456",
- CanDirectPlay: false,
- CanTranscode: true,
- TargetFormat: "mp3",
- TargetBitrate: 256, // kbps
- TargetChannels: 2,
- SourceUpdatedAt: sourceTime,
- }
- token, err := svc.CreateTranscodeParams(decision)
- Expect(err).ToNot(HaveOccurred())
-
- params, err := impl.parseTranscodeParams(token)
- Expect(err).ToNot(HaveOccurred())
- Expect(params.MediaID).To(Equal("media-456"))
- Expect(params.DirectPlay).To(BeFalse())
- Expect(params.TargetFormat).To(Equal("mp3"))
- Expect(params.TargetBitrate).To(Equal(256)) // kbps
- Expect(params.TargetChannels).To(Equal(2))
- Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix()))
- })
-
- It("creates and parses a transcode token with sample rate", func() {
- decision := &TranscodeDecision{
- MediaID: "media-789",
- CanDirectPlay: false,
- CanTranscode: true,
- TargetFormat: "flac",
- TargetBitrate: 0,
- TargetChannels: 2,
- TargetSampleRate: 48000,
- SourceUpdatedAt: sourceTime,
- }
- token, err := svc.CreateTranscodeParams(decision)
- Expect(err).ToNot(HaveOccurred())
-
- params, err := impl.parseTranscodeParams(token)
- Expect(err).ToNot(HaveOccurred())
- Expect(params.MediaID).To(Equal("media-789"))
- Expect(params.DirectPlay).To(BeFalse())
- Expect(params.TargetFormat).To(Equal("flac"))
- Expect(params.TargetSampleRate).To(Equal(48000))
- Expect(params.TargetChannels).To(Equal(2))
- })
-
- It("creates and parses a transcode token with bit depth", func() {
- decision := &TranscodeDecision{
- MediaID: "media-bd",
- CanDirectPlay: false,
- CanTranscode: true,
- TargetFormat: "flac",
- TargetBitrate: 0,
- TargetChannels: 2,
- TargetBitDepth: 24,
- SourceUpdatedAt: sourceTime,
- }
- token, err := svc.CreateTranscodeParams(decision)
- Expect(err).ToNot(HaveOccurred())
-
- params, err := impl.parseTranscodeParams(token)
- Expect(err).ToNot(HaveOccurred())
- Expect(params.MediaID).To(Equal("media-bd"))
- Expect(params.TargetBitDepth).To(Equal(24))
- })
-
- It("omits bit depth from token when 0", func() {
- decision := &TranscodeDecision{
- MediaID: "media-nobd",
- CanDirectPlay: false,
- CanTranscode: true,
- TargetFormat: "mp3",
- TargetBitrate: 256,
- TargetBitDepth: 0,
- SourceUpdatedAt: sourceTime,
- }
- token, err := svc.CreateTranscodeParams(decision)
- Expect(err).ToNot(HaveOccurred())
-
- params, err := impl.parseTranscodeParams(token)
- Expect(err).ToNot(HaveOccurred())
- Expect(params.TargetBitDepth).To(Equal(0))
- })
-
- It("omits sample rate from token when 0", func() {
- decision := &TranscodeDecision{
- MediaID: "media-100",
- CanDirectPlay: false,
- CanTranscode: true,
- TargetFormat: "mp3",
- TargetBitrate: 256,
- TargetSampleRate: 0,
- SourceUpdatedAt: sourceTime,
- }
- token, err := svc.CreateTranscodeParams(decision)
- Expect(err).ToNot(HaveOccurred())
-
- params, err := impl.parseTranscodeParams(token)
- Expect(err).ToNot(HaveOccurred())
- Expect(params.TargetSampleRate).To(Equal(0))
- })
-
- It("truncates SourceUpdatedAt to seconds", func() {
- timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC)
- decision := &TranscodeDecision{
- MediaID: "media-trunc",
- CanDirectPlay: true,
- SourceUpdatedAt: timeWithNanos,
- }
- token, err := svc.CreateTranscodeParams(decision)
- Expect(err).ToNot(HaveOccurred())
-
- params, err := impl.parseTranscodeParams(token)
- Expect(err).ToNot(HaveOccurred())
- Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix()))
- })
-
- It("rejects an invalid token", func() {
- _, err := impl.parseTranscodeParams("invalid-token")
- Expect(err).To(HaveOccurred())
- })
- })
-
- Describe("ResolveRequestFromToken", func() {
- var sourceTime time.Time
-
- BeforeEach(func() {
- sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
- })
-
- createTokenForMedia := func(mediaID string, updatedAt time.Time) string {
- decision := &TranscodeDecision{
- MediaID: mediaID,
- CanDirectPlay: true,
- SourceUpdatedAt: updatedAt,
- }
- token, err := svc.CreateTranscodeParams(decision)
- Expect(err).ToNot(HaveOccurred())
- return token
- }
-
- It("returns stream request for valid token", func() {
- mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
- token := createTokenForMedia("song-1", sourceTime)
-
- req, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
- Expect(err).ToNot(HaveOccurred())
- Expect(req.Format).To(BeEmpty()) // direct play has no target format
- })
-
- It("returns ErrTokenInvalid for invalid token", func() {
- mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
- _, err := svc.ResolveRequestFromToken(ctx, "bad-token", mf, 0)
- Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
- })
-
- It("returns ErrTokenInvalid when mediaID does not match token", func() {
- mf := &model.MediaFile{ID: "song-2", UpdatedAt: sourceTime}
- token := createTokenForMedia("song-1", sourceTime)
-
- _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
- Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
- })
-
- It("returns ErrTokenStale when media file has changed", func() {
- newTime := sourceTime.Add(1 * time.Hour)
- mf := &model.MediaFile{ID: "song-1", UpdatedAt: newTime}
- token := createTokenForMedia("song-1", sourceTime)
-
- _, 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() {
- It("returns error when media ID is missing", func() {
- tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
- token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)})
- Expect(err).NotTo(HaveOccurred())
-
- _, err = paramsFromToken(token)
- Expect(err).To(MatchError(ContainSubstring("missing media ID")))
- })
-
- It("returns error when source timestamp is missing", func() {
- tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
- token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"})
- Expect(err).NotTo(HaveOccurred())
-
- _, err = paramsFromToken(token)
- Expect(err).To(MatchError(ContainSubstring("missing source timestamp")))
- })
- })
-})
diff --git a/core/stream/types.go b/core/stream/types.go
deleted file mode 100644
index 300017c01..000000000
--- a/core/stream/types.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package stream
-
-import (
- "errors"
- "strings"
- "time"
-)
-
-var (
- ErrTokenInvalid = errors.New("invalid or expired transcode token")
- ErrTokenStale = errors.New("transcode token is stale: media file has changed")
-)
-
-// TranscodeOptions controls optional behavior of MakeTranscodeDecision.
-type TranscodeOptions struct {
- // SkipProbe prevents MakeTranscodeDecision from running ffprobe on the media file.
- // When true, source stream details are derived from tag metadata only.
- SkipProbe bool
-}
-
-// Request contains the resolved parameters for creating a media stream.
-type Request struct {
- Format string
- BitRate int // kbps
- SampleRate int
- BitDepth int
- Channels int
- Offset int // seconds
-}
-
-// ClientInfo represents client playback capabilities.
-// All bitrate values are in kilobits per second (kbps)
-type ClientInfo struct {
- Name string
- Platform string
- MaxAudioBitrate int
- MaxTranscodingAudioBitrate int
- DirectPlayProfiles []DirectPlayProfile
- TranscodingProfiles []Profile
- 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
- AudioCodecs []string
- Protocols []string
- MaxAudioChannels int
-}
-
-func (p DirectPlayProfile) String() string {
- containers := strings.Join(p.Containers, ",")
- if containers == "" {
- containers = "*"
- }
- codecs := strings.Join(p.AudioCodecs, ",")
- if codecs == "" {
- return "[" + containers + "]"
- }
- return "[" + containers + "/" + codecs + "]"
-}
-
-// Profile describes a transcoding target the client supports
-type Profile struct {
- Container string
- AudioCodec string
- Protocol string
- MaxAudioChannels int
-}
-
-// CodecProfile describes codec-specific limitations
-type CodecProfile struct {
- Type string
- Name string
- Limitations []Limitation
-}
-
-// Limitation describes a specific codec limitation
-type Limitation struct {
- Name string
- Comparison string
- Values []string
- Required bool
-}
-
-// Protocol values (OpenSubsonic spec enum)
-const (
- ProtocolHTTP = "http"
- ProtocolHLS = "hls"
-)
-
-// Comparison operators (OpenSubsonic spec enum)
-const (
- ComparisonEquals = "Equals"
- ComparisonNotEquals = "NotEquals"
- ComparisonLessThanEqual = "LessThanEqual"
- ComparisonGreaterThanEqual = "GreaterThanEqual"
-)
-
-// Limitation names (OpenSubsonic spec enum)
-const (
- LimitationAudioChannels = "audioChannels"
- LimitationAudioBitrate = "audioBitrate"
- LimitationAudioProfile = "audioProfile"
- LimitationAudioSamplerate = "audioSamplerate"
- LimitationAudioBitdepth = "audioBitdepth"
-)
-
-// Codec profile types (OpenSubsonic spec enum)
-const (
- CodecProfileTypeAudio = "AudioCodec"
-)
-
-// TranscodeDecision represents the internal decision result.
-// All bitrate values are in kilobits per second (kbps).
-type TranscodeDecision struct {
- MediaID string
- CanDirectPlay bool
- CanTranscode bool
- TranscodeReasons []string
- ErrorReason string
- TargetFormat string
- TargetBitrate int
- TargetChannels int
- TargetSampleRate int
- TargetBitDepth int
- SourceStream Details
- SourceUpdatedAt time.Time
- TranscodeStream *Details
-}
-
-// Details describes audio stream properties.
-// Bitrate is in kilobits per second (kbps).
-type Details struct {
- Container string
- Codec string
- Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data.
- Bitrate int
- SampleRate int
- BitDepth int
- Channels int
- Duration float32
- Size int64
- IsLossless bool
-}
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
deleted file mode 100644
index d67d69dcb..000000000
--- a/core/user.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package core
-
-import (
- "context"
-
- "github.com/deluan/rest"
- "github.com/navidrome/navidrome/model"
-)
-
-// PluginUnloader defines the interface for unloading disabled plugins.
-// This is satisfied by plugins.Manager but defined here to avoid import cycles.
-type PluginUnloader interface {
- UnloadDisabledPlugins(ctx context.Context)
-}
-
-// User provides business logic for user management with plugin coordination.
-type User interface {
- Repository() rest.Repository[model.User]
-}
-
-type userService struct {
- repo *userRepositoryWrapper
-}
-
-// NewUser creates a new User service
-func NewUser(ds model.DataStore, pluginManager PluginUnloader) User {
- return &userService{
- repo: &userRepositoryWrapper{
- UserRepository: ds.User(),
- pluginManager: pluginManager,
- },
- }
-}
-
-// Repository 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
-}
-
-type userRepositoryWrapper struct {
- model.UserRepository
- 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
-}
diff --git a/core/user_test.go b/core/user_test.go
deleted file mode 100644
index 880637770..000000000
--- a/core/user_test.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package core_test
-
-import (
- "context"
- "errors"
-
- "github.com/deluan/rest"
- "github.com/navidrome/navidrome/core"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("User Service", func() {
- var service core.User
- var ds *tests.MockDataStore
- var userRepo *tests.MockedUserRepo
- var pluginManager *mockPluginUnloader
- var ctx context.Context
-
- BeforeEach(func() {
- ds = &tests.MockDataStore{}
- userRepo = tests.CreateMockUserRepo()
- ds.MockedUser = userRepo
- pluginManager = &mockPluginUnloader{}
- service = core.NewUser(ds, pluginManager)
- ctx = GinkgoT().Context()
- })
-
- Describe("NewRepository", func() {
- It("returns a rest.Persistable[model.User]", func() {
- repo := service.Repository()
- _, ok := repo.(rest.Persistable[model.User])
- Expect(ok).To(BeTrue())
- })
- })
-
- Describe("Delete", func() {
- var repo rest.Persistable[model.User]
-
- BeforeEach(func() {
- r := service.Repository()
- repo = r.(rest.Persistable[model.User])
-
- // Add a test user
- user := &model.User{
- ID: "user-123",
- UserName: "testuser",
- IsAdmin: false,
- }
- user.NewPassword = "password"
- Expect(userRepo.Put(ctx, user)).To(Succeed())
- })
-
- It("deletes the user successfully", func() {
- err := repo.Delete(ctx, "user-123")
- Expect(err).NotTo(HaveOccurred())
-
- // Verify user is deleted
- _, err = userRepo.Get(ctx, "user-123")
- Expect(err).To(Equal(model.ErrNotFound))
- })
-
- It("calls UnloadDisabledPlugins after successful deletion", func() {
- err := repo.Delete(ctx, "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("returns error when repository fails", func() {
- userRepo.Error = errors.New("database error")
- err := repo.Delete(ctx, "user-123")
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("database error"))
- Expect(pluginManager.unloadCalls).To(Equal(1))
- })
- })
-})
diff --git a/core/wire_providers.go b/core/wire_providers.go
index b3df9b2dc..16335645c 100644
--- a/core/wire_providers.go
+++ b/core/wire_providers.go
@@ -5,35 +5,25 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
- "github.com/navidrome/navidrome/core/lyrics"
- "github.com/navidrome/navidrome/core/matcher"
"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"
)
var Set = wire.NewSet(
- stream.NewMediaStreamer,
- stream.GetTranscodingCache,
+ NewMediaStreamer,
+ GetTranscodingCache,
NewArchiver,
NewPlayers,
NewShare,
- playlists.NewPlaylists,
+ NewPlaylists,
NewLibrary,
- NewUser,
NewMaintenance,
- stream.NewTranscodeDecider,
agents.GetAgents,
external.NewProvider,
- matcher.New,
wire.Bind(new(external.Agents), new(*agents.Agents)),
ffmpeg.New,
scrobbler.GetPlayTracker,
playback.GetInstance,
- quickconnect.GetInstance,
metrics.GetInstance,
- lyrics.NewLyrics,
)
diff --git a/db/backup.go b/db/backup.go
index 74cae553e..8b0f18b1b 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)
@@ -94,12 +81,12 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
// Caution: -1 means that sqlite will hold a read lock until the operation finishes
// This will lock out other writes that could happen at the same time
done, err := backupOp.Step(-1)
- if err != nil {
- return fmt.Errorf("error during backup step: %w", err)
- }
if !done {
return fmt.Errorf("backup not done with step -1")
}
+ if err != nil {
+ return fmt.Errorf("error during backup step: %w", err)
+ }
err = backupOp.Finish()
if err != nil {
@@ -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..cb1ebd9e3 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,29 @@ 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)
}
+ _, err = db.Exec("PRAGMA optimize=0x10002")
+ if err != nil {
+ log.Error("Error applying PRAGMA optimize", err)
+ return nil
+ }
return db
})
}
@@ -71,6 +58,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 {
@@ -110,10 +100,10 @@ func Init(ctx context.Context) func() {
}
if hasSchemaChanges {
- log.Debug(ctx, "Running ANALYZE after schema changes")
- err = optimizeAt(ctx, db, time.Now())
+ 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,27 +112,38 @@ 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) {
+ 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 i := 0; i < numConns; i++ {
+ 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 }
-func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }
-func (l *statusLogger) Printf(format string, v ...any) {
+func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
+func (l *statusLogger) Printf(format string, v ...interface{}) {
if len(v) < 1 {
return
}
@@ -163,52 +164,41 @@ 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
}
-func (l *logAdapter) Fatal(v ...any) {
+func (l *logAdapter) Fatal(v ...interface{}) {
log.Fatal(l.ctx, fmt.Sprint(v...))
}
-func (l *logAdapter) Fatalf(format string, v ...any) {
+func (l *logAdapter) Fatalf(format string, v ...interface{}) {
log.Fatal(l.ctx, fmt.Sprintf(format, v...))
}
-func (l *logAdapter) Print(v ...any) {
+func (l *logAdapter) Print(v ...interface{}) {
if !l.silent {
log.Info(l.ctx, fmt.Sprint(v...))
}
}
-func (l *logAdapter) Println(v ...any) {
+func (l *logAdapter) Println(v ...interface{}) {
if !l.silent {
log.Info(l.ctx, fmt.Sprintln(v...))
}
}
-func (l *logAdapter) Printf(format string, v ...any) {
+func (l *logAdapter) Printf(format string, v ...interface{}) {
if !l.silent {
log.Info(l.ctx, fmt.Sprintf(format, v...))
}
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/20251109010105_add_annotation_rating_date.sql b/db/migrations/20251109010105_add_annotation_rating_date.sql
deleted file mode 100644
index 9dac46a5e..000000000
--- a/db/migrations/20251109010105_add_annotation_rating_date.sql
+++ /dev/null
@@ -1,7 +0,0 @@
--- +goose Up
--- +goose StatementBegin
-ALTER TABLE annotation ADD COLUMN rated_at datetime;
--- +goose StatementEnd
-
--- +goose Down
-
\ No newline at end of file
diff --git a/db/migrations/20251206013022_create_scrobbles_table.sql b/db/migrations/20251206013022_create_scrobbles_table.sql
deleted file mode 100644
index 9791c48e3..000000000
--- a/db/migrations/20251206013022_create_scrobbles_table.sql
+++ /dev/null
@@ -1,20 +0,0 @@
--- +goose Up
--- +goose StatementBegin
-CREATE TABLE scrobbles(
- 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
-);
-CREATE INDEX scrobbles_date ON scrobbles (submission_time);
--- +goose StatementEnd
-
--- +goose Down
--- +goose StatementBegin
-DROP TABLE scrobbles;
--- +goose StatementEnd
diff --git a/db/migrations/20260104203627_playlist_case_insensitive_name.sql b/db/migrations/20260104203627_playlist_case_insensitive_name.sql
deleted file mode 100644
index 64b079cca..000000000
--- a/db/migrations/20260104203627_playlist_case_insensitive_name.sql
+++ /dev/null
@@ -1,99 +0,0 @@
--- +goose Up
--- Fix case-insensitive sorting for playlist names
-create table playlist_dg_tmp
-(
- id varchar(255) not null
- primary key,
- name varchar(255) collate NOCASE default '' not null,
- comment varchar(255) default '' not null,
- duration real default 0 not null,
- song_count integer default 0 not null,
- public bool default FALSE not null,
- created_at datetime,
- updated_at datetime,
- path string default '' not null,
- sync bool default false not null,
- size integer default 0 not null,
- rules varchar,
- evaluated_at datetime,
- owner_id varchar(255) not null
- constraint playlist_user_user_id_fk
- references user
- on update cascade on delete cascade
-);
-
-insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size,
- rules, evaluated_at, owner_id)
-select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at,
- owner_id
-from playlist;
-
-drop table playlist;
-
-alter table playlist_dg_tmp
- rename to playlist;
-
-create index playlist_name
- on playlist (name);
-
-create index playlist_created_at
- on playlist (created_at);
-
-create index playlist_updated_at
- on playlist (updated_at);
-
-create index playlist_evaluated_at
- on playlist (evaluated_at);
-
-create index playlist_size
- on playlist (size);
-
--- +goose Down
--- Note: Downgrade loses the collation but preserves data
-create table playlist_dg_tmp
-(
- id varchar(255) not null
- primary key,
- name varchar(255) default '' not null,
- comment varchar(255) default '' not null,
- duration real default 0 not null,
- song_count integer default 0 not null,
- public bool default FALSE not null,
- created_at datetime,
- updated_at datetime,
- path string default '' not null,
- sync bool default false not null,
- size integer default 0 not null,
- rules varchar,
- evaluated_at datetime,
- owner_id varchar(255) not null
- constraint playlist_user_user_id_fk
- references user
- on update cascade on delete cascade
-);
-
-insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size,
- rules, evaluated_at, owner_id)
-select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at,
- owner_id
-from playlist;
-
-drop table playlist;
-
-alter table playlist_dg_tmp
- rename to playlist;
-
-create index playlist_name
- on playlist (name);
-
-create index playlist_created_at
- on playlist (created_at);
-
-create index playlist_updated_at
- on playlist (updated_at);
-
-create index playlist_evaluated_at
- on playlist (evaluated_at);
-
-create index playlist_size
- on playlist (size);
diff --git a/db/migrations/20260106000620_create_plugin_table.sql b/db/migrations/20260106000620_create_plugin_table.sql
deleted file mode 100644
index bcc83be0b..000000000
--- a/db/migrations/20260106000620_create_plugin_table.sql
+++ /dev/null
@@ -1,19 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS plugin (
- id TEXT PRIMARY KEY,
- path TEXT NOT NULL,
- manifest JSONB NOT NULL,
- config JSONB,
- users JSONB,
- all_users BOOL NOT NULL DEFAULT false,
- libraries JSONB,
- all_libraries BOOL NOT NULL DEFAULT false,
- enabled BOOL NOT NULL DEFAULT false,
- last_error TEXT,
- sha256 TEXT NOT NULL,
- created_at DATETIME NOT NULL,
- updated_at DATETIME NOT NULL
-);
-
--- +goose Down
-DROP TABLE IF EXISTS plugin;
diff --git a/db/migrations/20260117201522_add_avg_rating_column.sql b/db/migrations/20260117201522_add_avg_rating_column.sql
deleted file mode 100644
index f5c8d4522..000000000
--- a/db/migrations/20260117201522_add_avg_rating_column.sql
+++ /dev/null
@@ -1,23 +0,0 @@
--- +goose Up
-ALTER TABLE album ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
-ALTER TABLE media_file ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
-ALTER TABLE artist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
-
--- Populate average_rating from existing ratings
-UPDATE album SET average_rating = coalesce(
- (SELECT round(avg(rating), 2) FROM annotation WHERE item_id = album.id AND item_type = 'album' AND rating > 0),
- 0
-);
-UPDATE media_file SET average_rating = coalesce(
- (SELECT round(avg(rating), 2) FROM annotation WHERE item_id = media_file.id AND item_type = 'media_file' AND rating > 0),
- 0
-);
-UPDATE artist SET average_rating = coalesce(
- (SELECT round(avg(rating), 2) FROM annotation WHERE item_id = artist.id AND item_type = 'artist' AND rating > 0),
- 0
-);
-
--- +goose Down
-ALTER TABLE artist DROP COLUMN average_rating;
-ALTER TABLE media_file DROP COLUMN average_rating;
-ALTER TABLE album DROP COLUMN average_rating;
diff --git a/db/migrations/20260220173400_add_fts5_search.go b/db/migrations/20260220173400_add_fts5_search.go
deleted file mode 100644
index 6f2bde429..000000000
--- a/db/migrations/20260220173400_add_fts5_search.go
+++ /dev/null
@@ -1,391 +0,0 @@
-package migrations
-
-import (
- "context"
- "database/sql"
- "fmt"
-
- "github.com/pressly/goose/v3"
-)
-
-func init() {
- goose.AddMigrationContext(upAddFts5Search, downAddFts5Search)
-}
-
-// stripPunct generates a SQL expression that strips common punctuation from a column or expression.
-// Used during migration to approximate the Go normalizeForFTS function for bulk-populating search_normalized.
-func stripPunct(col string) string {
- return fmt.Sprintf(
- `REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(%s, '.', ''), '/', ''), '-', ''), '''', ''), '&', ''), ',', '')`,
- col,
- )
-}
-
-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.")
-
- // 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 ''`)
- if err != nil {
- return fmt.Errorf("adding search_participants to media_file: %w", err)
- }
- _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
- if err != nil {
- return fmt.Errorf("adding search_normalized to media_file: %w", err)
- }
- _, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
- if err != nil {
- return fmt.Errorf("adding search_participants to album: %w", err)
- }
- _, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
- if err != nil {
- return fmt.Errorf("adding search_normalized to album: %w", err)
- }
- _, err = tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
- if err != nil {
- return fmt.Errorf("adding search_normalized to artist: %w", err)
- }
-
- // Step 2: Populate search_participants from participants JSON.
- // Extract all "name" values from the participants JSON structure.
- // participants is a JSON object like: {"artist":[{"name":"...","id":"..."}],"albumartist":[...]}
- // We use json_each + json_extract to flatten all names into a space-separated string.
- _, err = tx.ExecContext(ctx, `
- UPDATE media_file SET search_participants = COALESCE(
- (SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
- FROM json_each(media_file.participants) AS je1,
- json_each(je1.value) AS je2
- WHERE json_extract(je2.value, '$.name') IS NOT NULL),
- ''
- )
- WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
- `)
- if err != nil {
- return fmt.Errorf("populating media_file search_participants: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- UPDATE album SET search_participants = COALESCE(
- (SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
- FROM json_each(album.participants) AS je1,
- json_each(je1.value) AS je2
- WHERE json_extract(je2.value, '$.name') IS NOT NULL),
- ''
- )
- WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
- `)
- if err != nil {
- return fmt.Errorf("populating album search_participants: %w", err)
- }
-
- // Step 2b: Populate search_normalized using SQL REPLACE chains for common punctuation.
- // The Go code will compute the precise value on next scan; this is a best-effort approximation.
- _, err = tx.ExecContext(ctx, fmt.Sprintf(`
- UPDATE artist SET search_normalized = %s
- WHERE name != %s`,
- stripPunct("name"), stripPunct("name")))
- if err != nil {
- return fmt.Errorf("populating artist search_normalized: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, fmt.Sprintf(`
- UPDATE album SET search_normalized = TRIM(%s || ' ' || %s)
- WHERE name != %s OR COALESCE(album_artist, '') != %s`,
- stripPunct("name"), stripPunct("COALESCE(album_artist, '')"),
- stripPunct("name"), stripPunct("COALESCE(album_artist, '')")))
- if err != nil {
- return fmt.Errorf("populating album search_normalized: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, fmt.Sprintf(`
- UPDATE media_file SET search_normalized =
- TRIM(%s || ' ' || %s || ' ' || %s || ' ' || %s)
- WHERE title != %s
- OR COALESCE(album, '') != %s
- OR COALESCE(artist, '') != %s
- OR COALESCE(album_artist, '') != %s`,
- stripPunct("title"), stripPunct("COALESCE(album, '')"),
- stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')"),
- stripPunct("title"), stripPunct("COALESCE(album, '')"),
- stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')")))
- if err != nil {
- return fmt.Errorf("populating media_file search_normalized: %w", err)
- }
-
- // Step 3: Create FTS5 virtual tables
- _, err = tx.ExecContext(ctx, `
- 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'
- )
- `)
- if err != nil {
- return fmt.Errorf("creating media_file_fts: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- 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'
- )
- `)
- if err != nil {
- return fmt.Errorf("creating album_fts: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- 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'
- )
- `)
- if err != nil {
- return fmt.Errorf("creating artist_fts: %w", err)
- }
-
- // Step 4: Bulk-populate FTS5 indexes from existing data
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("populating media_file_fts: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("populating album_fts: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
- SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '')
- FROM artist
- `)
- if err != nil {
- return fmt.Errorf("populating artist_fts: %w", err)
- }
-
- // Step 5: Create triggers for media_file
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("creating media_file_fts insert trigger: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("creating media_file_fts delete trigger: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("creating media_file_fts update trigger: %w", err)
- }
-
- // Step 6: Create triggers for album
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("creating album_fts insert trigger: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("creating album_fts delete trigger: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("creating album_fts update trigger: %w", err)
- }
-
- // Step 7: Create triggers for artist
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("creating artist_fts insert trigger: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("creating artist_fts delete trigger: %w", err)
- }
-
- _, err = tx.ExecContext(ctx, `
- 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
- `)
- if err != nil {
- return fmt.Errorf("creating artist_fts update trigger: %w", err)
- }
-
- return nil
-}
-
-func downAddFts5Search(ctx context.Context, tx *sql.Tx) error {
- for _, trigger := range []string{
- "media_file_fts_ai", "media_file_fts_ad", "media_file_fts_au",
- "album_fts_ai", "album_fts_ad", "album_fts_au",
- "artist_fts_ai", "artist_fts_ad", "artist_fts_au",
- } {
- _, err := tx.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
- if err != nil {
- return fmt.Errorf("dropping trigger %s: %w", trigger, err)
- }
- }
-
- for _, table := range []string{"media_file_fts", "album_fts", "artist_fts"} {
- _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table)
- if err != nil {
- return fmt.Errorf("dropping table %s: %w", table, err)
- }
- }
-
- // Note: We don't drop search_participants columns because SQLite doesn't support DROP COLUMN
- // on older versions, and the column is harmless if left in place.
- return nil
-}
diff --git a/db/migrations/20260228020813_add_plugin_allow_write_access.sql b/db/migrations/20260228020813_add_plugin_allow_write_access.sql
deleted file mode 100644
index e17d874a5..000000000
--- a/db/migrations/20260228020813_add_plugin_allow_write_access.sql
+++ /dev/null
@@ -1,5 +0,0 @@
--- +goose Up
-ALTER TABLE plugin ADD COLUMN allow_write_access BOOL NOT NULL DEFAULT false;
-
--- +goose Down
-ALTER TABLE plugin DROP COLUMN allow_write_access;
diff --git a/db/migrations/20260228172956_add_playlist_image_file.go b/db/migrations/20260228172956_add_playlist_image_file.go
deleted file mode 100644
index da2177aba..000000000
--- a/db/migrations/20260228172956_add_playlist_image_file.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package migrations
-
-import (
- "context"
- "database/sql"
-
- "github.com/pressly/goose/v3"
-)
-
-func init() {
- goose.AddMigrationContext(upAddPlaylistImageFile, downAddPlaylistImageFile)
-}
-
-func upAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error {
- _, err := tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN image_file VARCHAR(255) DEFAULT '';`)
- return err
-}
-
-func downAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error {
- _, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN image_file;`)
- return err
-}
diff --git a/db/migrations/20260302021413_rename_playlist_image_fields.go b/db/migrations/20260302021413_rename_playlist_image_fields.go
deleted file mode 100644
index 1e9754637..000000000
--- a/db/migrations/20260302021413_rename_playlist_image_fields.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package migrations
-
-import (
- "context"
- "database/sql"
-
- "github.com/pressly/goose/v3"
-)
-
-func init() {
- goose.AddMigrationContext(upRenamePlaylistImageFields, downRenamePlaylistImageFields)
-}
-
-func upRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error {
- _, err := tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN image_file TO uploaded_image;`)
- if err != nil {
- return err
- }
- _, err = tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN external_image_url VARCHAR(255) DEFAULT '';`)
- return err
-}
-
-func downRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error {
- _, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN external_image_url;`)
- if err != nil {
- return err
- }
- _, err = tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN uploaded_image TO image_file;`)
- return err
-}
diff --git a/db/migrations/20260307175815_add_codec_and_update_transcodings.go b/db/migrations/20260307175815_add_codec_and_update_transcodings.go
deleted file mode 100644
index f52f48440..000000000
--- a/db/migrations/20260307175815_add_codec_and_update_transcodings.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package migrations
-
-import (
- "context"
- "database/sql"
-
- "github.com/navidrome/navidrome/model/id"
- "github.com/pressly/goose/v3"
-)
-
-func init() {
- goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings)
-}
-
-func upAddCodecAndUpdateTranscodings(ctx 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`)
- if err != nil {
- return err
- }
- _, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`)
- if err != nil {
- return err
- }
-
- // Update old AAC default (adts) to new default (ipod with fragmented MP4).
- // Only affects users who still have the unmodified old default command.
- _, err = tx.ExecContext(ctx,
- `UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`,
- "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
- "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
- )
- if err != nil {
- return err
- }
-
- // 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)
- if err != nil {
- return err
- }
- if count == 0 {
- _, err = tx.ExecContext(ctx,
- "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
- id.NewRandom(), "flac audio", "flac", 0,
- "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
- )
- if err != nil {
- return err
- }
- }
-
- // Add probe_data column for caching ffprobe results.
- _, err = tx.ExecContext(ctx, `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`)
- if err != nil {
- return err
- }
- _, err = tx.ExecContext(ctx, `DROP INDEX IF EXISTS media_file_codec`)
- if err != nil {
- return err
- }
- _, err = tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN codec`)
- return err
-}
diff --git a/db/migrations/20260309120007_fix_probe_data_null.go b/db/migrations/20260309120007_fix_probe_data_null.go
deleted file mode 100644
index c76d6ed1a..000000000
--- a/db/migrations/20260309120007_fix_probe_data_null.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package migrations
-
-import (
- "context"
- "database/sql"
-
- "github.com/pressly/goose/v3"
-)
-
-func init() {
- goose.AddMigrationContext(upFixProbeDataNull, downFixProbeDataNull)
-}
-
-func upFixProbeDataNull(ctx context.Context, tx *sql.Tx) error {
- // Recreate probe_data column as NOT NULL with empty string default.
- // The previous migration created it with DEFAULT NULL, which causes
- // scan errors when reading into Go string fields.
- _, err := tx.ExecContext(ctx, `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`)
- return err
-}
-
-func downFixProbeDataNull(_ context.Context, _ *sql.Tx) error {
- return nil
-}
diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go
deleted file mode 100644
index 4df66d26d..000000000
--- a/db/migrations/20260309203355_ensure_default_transcodings.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package migrations
-
-import (
- "context"
- "database/sql"
-
- "github.com/navidrome/navidrome/consts"
- "github.com/navidrome/navidrome/model/id"
- "github.com/pressly/goose/v3"
-)
-
-func init() {
- goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings)
-}
-
-func upEnsureDefaultTranscodings(ctx context.Context, tx *sql.Tx) error {
- // Older installations may be missing default transcodings that were added
- // after the initial seeding (e.g., aac was added later than mp3/opus).
- // Insert any missing defaults without touching user-customized entries.
- // Check both target_format and name since both have UNIQUE constraints,
- // and older entries may have a different target_format (e.g., 'oga' vs 'opus')
- // 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)
- if err != nil {
- return err
- }
- if count == 0 {
- _, err = tx.ExecContext(ctx,
- "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
- id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command,
- )
- if err != nil {
- return err
- }
- }
- }
- return nil
-}
-
-func downEnsureDefaultTranscodings(_ context.Context, _ *sql.Tx) error {
- return nil
-}
diff --git a/db/migrations/20260310113858_fix_aac_transcode_command.go b/db/migrations/20260310113858_fix_aac_transcode_command.go
deleted file mode 100644
index a4fa8fcc1..000000000
--- a/db/migrations/20260310113858_fix_aac_transcode_command.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package migrations
-
-import (
- "context"
- "database/sql"
-
- "github.com/pressly/goose/v3"
-)
-
-func init() {
- goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand)
-}
-
-func upFixAacTranscodeCommand(ctx context.Context, tx *sql.Tx) error {
- // The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces
- // corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+).
- // Switch to `-f adts` (raw AAC framing) which works reliably via pipe.
- // Only update rows that still have the old default command.
- const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"
- const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -"
- _, err := tx.ExecContext(ctx,
- "UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?",
- newCommand, oldCommand,
- )
- return err
-}
-
-func downFixAacTranscodeCommand(_ context.Context, _ *sql.Tx) error {
- return nil
-}
diff --git a/db/migrations/20260315233131_add_artist_uploaded_image.go b/db/migrations/20260315233131_add_artist_uploaded_image.go
deleted file mode 100644
index 964e346f5..000000000
--- a/db/migrations/20260315233131_add_artist_uploaded_image.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package migrations
-
-import (
- "context"
- "database/sql"
-
- "github.com/pressly/goose/v3"
-)
-
-func init() {
- goose.AddMigrationContext(upAddArtistUploadedImage, downAddArtistUploadedImage)
-}
-
-func upAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error {
- _, err := tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN uploaded_image VARCHAR(255) DEFAULT ''`)
- return err
-}
-
-func downAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error {
- // This code is executed when the migration is rolled back.
- return nil
-}
diff --git a/db/migrations/20260316000000_normalize_timestamps.sql b/db/migrations/20260316000000_normalize_timestamps.sql
deleted file mode 100644
index a2e1183e9..000000000
--- a/db/migrations/20260316000000_normalize_timestamps.sql
+++ /dev/null
@@ -1,74 +0,0 @@
--- +goose Up
-
--- Normalize T-format timestamps (RFC3339Nano with 'T' separator) to SQLite-compatible format.
--- SQLite uses string comparison for ORDER BY on TEXT columns, so 'T' (ASCII 84) > ' ' (ASCII 32)
--- causes T-format timestamps to sort after space-format ones, breaking "Recently Added" ordering.
-
-UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE album SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-UPDATE album SET imported_at = replace(replace(imported_at, 'T', ' '), 'Z', '+00:00') WHERE imported_at LIKE '%T%';
-UPDATE album SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%';
-
-UPDATE media_file SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE media_file SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-UPDATE media_file SET birth_time = replace(replace(birth_time, 'T', ' '), 'Z', '+00:00') WHERE birth_time LIKE '%T%';
-
-UPDATE artist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE artist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-UPDATE artist SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%';
-
-UPDATE annotation SET play_date = replace(replace(play_date, 'T', ' '), 'Z', '+00:00') WHERE play_date LIKE '%T%';
-UPDATE annotation SET starred_at = replace(replace(starred_at, 'T', ' '), 'Z', '+00:00') WHERE starred_at LIKE '%T%';
-UPDATE annotation SET rated_at = replace(replace(rated_at, 'T', ' '), 'Z', '+00:00') WHERE rated_at LIKE '%T%';
-
-UPDATE playlist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE playlist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-UPDATE playlist SET evaluated_at = replace(replace(evaluated_at, 'T', ' '), 'Z', '+00:00') WHERE evaluated_at LIKE '%T%';
-
-UPDATE user SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE user SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-UPDATE user SET last_login_at = replace(replace(last_login_at, 'T', ' '), 'Z', '+00:00') WHERE last_login_at LIKE '%T%';
-UPDATE user SET last_access_at = replace(replace(last_access_at, 'T', ' '), 'Z', '+00:00') WHERE last_access_at LIKE '%T%';
-
-UPDATE player SET last_seen = replace(replace(last_seen, 'T', ' '), 'Z', '+00:00') WHERE last_seen LIKE '%T%';
-
-UPDATE playqueue SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE playqueue SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-
-UPDATE bookmark SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE bookmark SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-
-UPDATE share SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE share SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-UPDATE share SET expires_at = replace(replace(expires_at, 'T', ' '), 'Z', '+00:00') WHERE expires_at LIKE '%T%';
-UPDATE share SET last_visited_at = replace(replace(last_visited_at, 'T', ' '), 'Z', '+00:00') WHERE last_visited_at LIKE '%T%';
-
-UPDATE radio SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE radio SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-
-UPDATE folder SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE folder SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-UPDATE folder SET images_updated_at = replace(replace(images_updated_at, 'T', ' '), 'Z', '+00:00') WHERE images_updated_at LIKE '%T%';
-
-UPDATE library SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE library SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-UPDATE library SET last_scan_at = replace(replace(last_scan_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_at LIKE '%T%';
-UPDATE library SET last_scan_started_at = replace(replace(last_scan_started_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_started_at LIKE '%T%';
-
-UPDATE scrobble_buffer SET play_time = replace(replace(play_time, 'T', ' '), 'Z', '+00:00') WHERE play_time LIKE '%T%';
-UPDATE scrobble_buffer SET enqueue_time = replace(replace(enqueue_time, 'T', ' '), 'Z', '+00:00') WHERE enqueue_time LIKE '%T%';
-
-UPDATE plugin SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
-UPDATE plugin SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
-
--- Replace plain indexes with expression indexes for datetime()-based sorting
-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));
-
--- +goose Down
-DROP INDEX IF EXISTS album_created_at;
-CREATE INDEX album_created_at ON album(created_at);
-DROP INDEX IF EXISTS album_updated_at;
-CREATE INDEX album_updated_at ON album(updated_at);
diff --git a/db/migrations/20260318182414_add_radio_uploaded_image.go b/db/migrations/20260318182414_add_radio_uploaded_image.go
deleted file mode 100644
index e92a6d2ef..000000000
--- a/db/migrations/20260318182414_add_radio_uploaded_image.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package migrations
-
-import (
- "context"
- "database/sql"
-
- "github.com/pressly/goose/v3"
-)
-
-func init() {
- goose.AddMigrationContext(upAddRadioUploadedImage, downAddRadioUploadedImage)
-}
-
-func upAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
- _, err := tx.ExecContext(ctx, `ALTER TABLE radio ADD COLUMN uploaded_image VARCHAR(255) NOT NULL DEFAULT ''`)
- return err
-}
-
-func downAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error {
- // This code is executed when the migration is rolled back.
- return nil
-}
diff --git a/db/migrations/20260405124200_fix_schema_inconsistencies.sql b/db/migrations/20260405124200_fix_schema_inconsistencies.sql
deleted file mode 100644
index 15fe95308..000000000
--- a/db/migrations/20260405124200_fix_schema_inconsistencies.sql
+++ /dev/null
@@ -1,55 +0,0 @@
--- +goose Up
-
--- NOTE: This migration recreates two tables to fix schema inconsistencies.
--- On large production databases, the data copy may take some time as tables are locked during the transaction.
--- This is necessary because SQLite does not support altering table constraints directly.
--- Consider applying this migration during a maintenance window if the tables are large.
-
--- Fix library_artist table: Remove contradictory 'default null' from 'not null' column
--- This is a cosmetic fix (NOT NULL takes precedence), but improves schema consistency
-CREATE TABLE library_artist_new
-(
- library_id integer NOT NULL DEFAULT 1
- REFERENCES library(id) ON DELETE CASCADE,
- artist_id varchar NOT NULL
- REFERENCES artist(id) ON DELETE CASCADE,
- stats text DEFAULT '{}',
- CONSTRAINT library_artist_ux UNIQUE (library_id, artist_id)
-);
-
-INSERT INTO library_artist_new (library_id, artist_id, stats)
-SELECT library_id, artist_id, stats FROM library_artist;
-
-DROP TABLE library_artist;
-
-ALTER TABLE library_artist_new RENAME TO library_artist;
-
--- Fix scrobble_buffer table: Remove duplicate user_id from unique constraint
--- Original constraint had: UNIQUE (user_id, service, media_file_id, play_time, user_id)
--- Fixed constraint is: UNIQUE (user_id, service, media_file_id, play_time)
-CREATE TABLE scrobble_buffer_new
-(
- user_id varchar NOT NULL
- CONSTRAINT scrobble_buffer_user_id_fk
- REFERENCES user ON UPDATE CASCADE ON DELETE CASCADE,
- service varchar NOT NULL,
- media_file_id varchar NOT NULL
- CONSTRAINT scrobble_buffer_media_file_id_fk
- REFERENCES media_file ON UPDATE CASCADE ON DELETE CASCADE,
- play_time datetime NOT NULL,
- enqueue_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
- id varchar NOT NULL DEFAULT '',
- CONSTRAINT scrobble_buffer_pk UNIQUE (user_id, service, media_file_id, play_time)
-);
-
-INSERT INTO scrobble_buffer_new (user_id, service, media_file_id, play_time, enqueue_time, id)
-SELECT user_id, service, media_file_id, play_time, enqueue_time, id FROM scrobble_buffer;
-
-DROP TABLE scrobble_buffer;
-
-ALTER TABLE scrobble_buffer_new RENAME TO scrobble_buffer;
-
-CREATE UNIQUE INDEX scrobble_buffer_id_ix ON scrobble_buffer (id);
-
--- +goose Down
--- Down migration is intentionally a no-op: Navidrome does not run down migrations.
diff --git a/db/migrations/20260410201914_fix_zero_album_created_at.sql b/db/migrations/20260410201914_fix_zero_album_created_at.sql
deleted file mode 100644
index ff47eb95f..000000000
--- a/db/migrations/20260410201914_fix_zero_album_created_at.sql
+++ /dev/null
@@ -1,22 +0,0 @@
--- +goose Up
-
--- Backfill album.created_at for rows poisoned by early scanner versions or
--- propagated via CopyAttributes during metadata-driven ID changes. Prefer the
--- oldest valid birth_time from the album's media files, fall back to updated_at.
-UPDATE album
-SET created_at = COALESCE(
- (SELECT MIN(birth_time)
- FROM media_file
- WHERE media_file.album_id = album.id
- AND birth_time IS NOT NULL
- AND birth_time != ''
- AND birth_time NOT LIKE '0001-%'),
- updated_at
-)
-WHERE created_at IS NULL
- OR created_at = ''
- OR created_at LIKE '0001-%';
-
--- +goose Down
-
-SELECT 1;
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..8d8f8a91e 100644
--- a/db/migrations/migration.go
+++ b/db/migrations/migration.go
@@ -11,16 +11,21 @@ import (
)
// 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`.
+ _, 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 +41,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..bbe610710 100644
--- a/go.mod
+++ b/go.mod
@@ -1,148 +1,141 @@
module github.com/navidrome/navidrome
-go 1.27
+go 1.25.4
-// Fork to implement raw tags support
-replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb
+replace (
+ // Fork to fix https://github.com/navidrome/navidrome/issues/3254
+ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
+ // Using version from main that fixes https://github.com/navidrome/navidrome/issues/4396
+ github.com/tetratelabs/wazero v1.9.0 => github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684
+)
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/RaveNoX/go-jsoncommentstrip v1.0.0
+ github.com/andybalholm/cascadia v1.3.3
+ github.com/bmatcuk/doublestar/v4 v4.9.1
+ github.com/bradleyjkemp/cupaloy/v2 v2.8.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/dhowden/tag v0.0.0-20240417053706-3d75831295e8
+ github.com/disintegration/imaging v1.6.2
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/extism/go-sdk v1.7.1
+ github.com/dustin/go-humanize v1.0.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/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
- github.com/go-chi/httprate v0.16.0
- github.com/go-chi/jwtauth/v5 v5.4.0
+ github.com/go-chi/httprate v0.15.0
+ github.com/go-chi/jwtauth/v5 v5.3.3
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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
+ github.com/knqyf263/go-plugin v0.9.0
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/v2 v2.1.6
+ github.com/matoous/go-nanoid/v2 v2.1.0
+ github.com/mattn/go-sqlite3 v1.14.32
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/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/onsi/ginkgo/v2 v2.27.2
+ github.com/onsi/gomega v1.38.2
+ github.com/pelletier/go-toml/v2 v2.2.4
+ github.com/pocketbase/dbx v1.11.0
+ github.com/pressly/goose/v3 v3.26.0
+ 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/spf13/cobra v1.10.2
+ github.com/sirupsen/logrus v1.9.3
+ github.com/spf13/cobra v1.10.1
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.9.0
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/exp v0.0.0-20251023183803-a4bb9ffd2546
+ golang.org/x/image v0.32.0
+ golang.org/x/net v0.46.0
+ golang.org/x/sync v0.17.0
+ golang.org/x/sys v0.37.0
+ golang.org/x/text v0.30.0
+ golang.org/x/time v0.14.0
+ google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.2 // indirect
- github.com/Masterminds/semver/v3 v3.5.0 // indirect
+ github.com/Masterminds/semver/v3 v3.4.0 // indirect
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/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/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/creack/pty v1.1.11 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // 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/goccy/go-json v0.10.6 // indirect
- github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/goccy/go-yaml v1.18.0 // 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-20251007162407-5df77e3f7d1d // 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/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-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/option/v2 v2.0.0 // indirect
- github.com/maruel/natural v1.3.0 // indirect
+ github.com/lestrrat-go/httprc v1.0.6 // indirect
+ github.com/lestrrat-go/iter v1.0.2 // indirect
+ github.com/lestrrat-go/option v1.0.1 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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.66.1 // indirect
+ github.com/prometheus/procfs v0.16.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
github.com/spf13/pflag v1.0.10 // indirect
- github.com/stretchr/objx v0.5.3 // indirect
+ github.com/stretchr/objx v0.5.2 // indirect
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.0.2 // 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.2 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/crypto v0.43.0 // indirect
+ golang.org/x/mod v0.29.0 // indirect
+ golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
+ golang.org/x/tools v0.38.0 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)
diff --git a/go.sum b/go.sum
index fe6dbbc3f..059ddd19f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,126 +1,116 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
-filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
-filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
-github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
-github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.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/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0=
+github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+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.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
+github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
+github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
+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.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
-github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
+github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/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/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.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
+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/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d h1:x/R3+oPEjnisl1zBx2f2v7Gf6f11l0N0JoD6BkwcJyA=
+github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
+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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
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/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/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/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/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.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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
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.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
+github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
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/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-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.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
+github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
+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/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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+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-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
+github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
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 +118,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/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.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+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/knqyf263/go-plugin v0.9.0 h1:CQs2+lOPIlkZVtcb835ZYDEoyyWJWLbSTWeCs0EwTwI=
+github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q=
+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 +152,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-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/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/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
+github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
+github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
+github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
+github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
+github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
+github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
+github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
+github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
+github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
+github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
+github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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=
@@ -186,38 +182,35 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
-github.com/onsi/ginkgo/v2 v2.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.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
+github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
+github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
+github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/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=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-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/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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
+github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
+github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
+github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
+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.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
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 +218,8 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/rogpeppe/go-internal v1.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,49 +227,46 @@ 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/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.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+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=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
-github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
-github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
+github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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.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.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+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/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
-github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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 v0.0.0-20251106165119-514cdb337684 h1:ugT1JTRsK1Jhn95BWilCugyZ1Svsyxm9xSiflOa2e7E=
+github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
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=
@@ -287,74 +277,135 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
-github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
-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=
+github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
+github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
+golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
+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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
+golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
+golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
+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/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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
+golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/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.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
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.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
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.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
+modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
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
deleted file mode 100644
index dd7cf5400..000000000
--- a/log/journal.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package log
-
-import (
- "fmt"
-
- "github.com/sirupsen/logrus"
-)
-
-// journalFormatter wraps a logrus.Formatter and prepends a syslog priority
-// prefix () to each log line. When stderr is captured by systemd-journald,
-// this prefix tells journald the correct severity for each message.
-//
-// See https://www.freedesktop.org/software/systemd/man/sd-daemon.html
-type journalFormatter struct {
- inner logrus.Formatter
-}
-
-// levelToPriority maps logrus levels to syslog priority values.
-// The mapping follows RFC 5424 severity levels.
-var levelToPriority = map[logrus.Level]int{
- logrus.PanicLevel: 0, // emerg
- logrus.FatalLevel: 2, // crit
- logrus.ErrorLevel: 3, // err
- logrus.WarnLevel: 4, // warning
- logrus.InfoLevel: 6, // info
- logrus.DebugLevel: 7, // debug
- logrus.TraceLevel: 7, // debug
-}
-
-func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) {
- formatted, err := f.inner.Format(entry)
- if err != nil {
- return formatted, err
- }
- priority, ok := levelToPriority[entry.Level]
- if !ok {
- priority = 6 // default to info for unknown levels
- }
- prefix := fmt.Appendf(nil, "<%d>", priority)
- return append(prefix, formatted...), nil
-}
diff --git a/log/journal_test.go b/log/journal_test.go
deleted file mode 100644
index 770f12b6d..000000000
--- a/log/journal_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package log
-
-import (
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
- "github.com/sirupsen/logrus"
-)
-
-var _ = Describe("journalFormatter", func() {
- var formatter *journalFormatter
-
- BeforeEach(func() {
- inner := &logrus.TextFormatter{
- DisableTimestamp: true,
- DisableColors: true,
- }
- formatter = &journalFormatter{inner: inner}
- })
-
- DescribeTable("prefixes log lines with syslog priority",
- func(level logrus.Level, expectedPrefix string) {
- entry := &logrus.Entry{
- Logger: logrus.New(),
- Level: level,
- Message: "test message",
- Data: logrus.Fields{},
- }
- out, err := formatter.Format(entry)
- Expect(err).ToNot(HaveOccurred())
- Expect(string(out)).To(HavePrefix(expectedPrefix))
- },
- Entry("error", logrus.ErrorLevel, "<3>"),
- Entry("warning", logrus.WarnLevel, "<4>"),
- Entry("info", logrus.InfoLevel, "<6>"),
- Entry("debug", logrus.DebugLevel, "<7>"),
- Entry("trace", logrus.TraceLevel, "<7>"),
- Entry("fatal", logrus.FatalLevel, "<2>"),
- Entry("panic", logrus.PanicLevel, "<0>"),
- Entry("unknown level defaults to info", logrus.Level(99), "<6>"),
- )
-})
diff --git a/log/log.go b/log/log.go
index da1d7622e..20119ab46 100644
--- a/log/log.go
+++ b/log/log.go
@@ -1,7 +1,6 @@
package log
import (
- "cmp"
"context"
"errors"
"fmt"
@@ -10,10 +9,8 @@ import (
"net/http"
"os"
"runtime"
- "slices"
+ "sort"
"strings"
- "sync"
- "sync/atomic"
"time"
"github.com/sirupsen/logrus"
@@ -21,22 +18,21 @@ import (
type Level uint32
-type LevelFunc = func(ctx any, msg any, keyValuePairs ...any)
+type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{})
var redacted = &Hook{
AcceptedLevels: logrus.AllLevels,
RedactionList: []string{
// Keys from the config
- "(ApiKey:[\\s]*\")[\\w]*",
- "(Secret:[\\s]*\")[\\w]*",
+ "(ApiKey:\")[\\w]*",
+ "(Secret:\")[\\w]*",
+ "(Spotify.*ID:\")[\\w]*",
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
- "(UserHeader:[\\s]*\")[^\"]*",
- "(TrustedSources:[\\s]*\")[^\"]*",
+ "(ReverseProxyUserHeader:[\\s]*\")[^\"]*",
+ "(ReverseProxyWhitelist:[\\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,30 +69,26 @@ type levelPath struct {
}
var (
- currentLevel atomic.Uint32
- hasLogLevelOverrides atomic.Bool
- loggerMu sync.RWMutex
- defaultLogger = logrus.New()
- logSourceLine = false
- rootPath string
- logLevels []levelPath
+ currentLevel Level
+ 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))
}
func SetLevelString(l string) {
- level := ParseLogLevel(l)
+ level := levelFromString(l)
SetLevel(level)
}
-func ParseLogLevel(l string) Level {
+func levelFromString(l string) Level {
envLevel := strings.ToLower(l)
var level Level
switch envLevel {
@@ -125,16 +110,13 @@ func ParseLogLevel(l string) Level {
// SetLogLevels sets the log levels for specific paths in the codebase.
func SetLogLevels(levels map[string]string) {
- loggerMu.Lock()
- defer loggerMu.Unlock()
logLevels = nil
for k, v := range levels {
- logLevels = append(logLevels, levelPath{path: k, level: ParseLogLevel(v)})
+ logLevels = append(logLevels, levelPath{path: k, level: levelFromString(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) {
@@ -143,8 +125,6 @@ func SetLogSourceLine(enabled bool) {
func SetRedacting(enabled bool) {
if enabled {
- loggerMu.Lock()
- defer loggerMu.Unlock()
defaultLogger.AddHook(redacted)
}
}
@@ -153,27 +133,16 @@ func SetOutput(w io.Writer) {
if runtime.GOOS == "windows" {
w = CRLFWriter(w)
}
- loggerMu.Lock()
- defer loggerMu.Unlock()
defaultLogger.SetOutput(w)
}
-// EnableJournalFormat wraps the current logger formatter with syslog
-// priority prefixes for systemd-journald. Only call this when output
-// goes to stderr and JOURNAL_STREAM is set.
-func EnableJournalFormat() {
- loggerMu.Lock()
- defer loggerMu.Unlock()
- defaultLogger.Formatter = &journalFormatter{inner: defaultLogger.Formatter}
-}
-
// Redact applies redaction to a single string
func Redact(msg string) string {
r, _ := redacted.redact(msg)
return r
}
-func NewContext(ctx context.Context, keyValuePairs ...any) context.Context {
+func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context {
if ctx == nil {
ctx = context.Background()
}
@@ -188,18 +157,12 @@ 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 {
- loggerMu.Lock()
- defer loggerMu.Unlock()
- prev := defaultLogger
+func SetDefaultLogger(l *logrus.Logger) {
defaultLogger = l
- return prev
}
func CurrentLevel() Level {
- return Level(currentLevel.Load())
+ return currentLevel
}
// IsGreaterOrEqualTo returns true if the caller's current log level is equal or greater than the provided level.
@@ -207,70 +170,58 @@ func IsGreaterOrEqualTo(level Level) bool {
return shouldLog(level, 2)
}
-func Fatal(args ...any) {
+func Fatal(args ...interface{}) {
log(LevelFatal, args...)
os.Exit(1)
}
-func Error(args ...any) {
+func Error(args ...interface{}) {
log(LevelError, args...)
}
-func Warn(args ...any) {
+func Warn(args ...interface{}) {
log(LevelWarn, args...)
}
-func Info(args ...any) {
+func Info(args ...interface{}) {
log(LevelInfo, args...)
}
-func Debug(args ...any) {
+func Debug(args ...interface{}) {
log(LevelDebug, args...)
}
-func Trace(args ...any) {
+func Trace(args ...interface{}) {
log(LevelTrace, args...)
}
-func Log(level Level, args ...any) {
- log(level, args...)
-}
-
-func log(level Level, args ...any) {
+func log(level Level, args ...interface{}) {
if !shouldLog(level, 3) {
return
}
-
logger, msg := parseArgs(args)
logger.Log(logrus.Level(level), msg)
}
func Writer() io.Writer {
- loggerMu.RLock()
- defer loggerMu.RUnlock()
return defaultLogger.Writer()
}
func shouldLog(requiredLevel Level, skip int) bool {
- level := Level(currentLevel.Load())
- if level >= requiredLevel {
+ if currentLevel >= requiredLevel {
return true
}
- if !hasLogLevelOverrides.Load() {
+ if len(logLevels) == 0 {
return false
}
- loggerMu.RLock()
- levels := logLevels
- loggerMu.RUnlock()
-
_, file, _, ok := runtime.Caller(skip)
if !ok {
return false
}
file = strings.TrimPrefix(file, rootPath)
- for _, lp := range levels {
+ for _, lp := range logLevels {
if strings.HasPrefix(file, lp.path) {
return lp.level >= requiredLevel
}
@@ -278,7 +229,7 @@ func shouldLog(requiredLevel Level, skip int) bool {
return false
}
-func parseArgs(args []any) (*logrus.Entry, string) {
+func parseArgs(args []interface{}) (*logrus.Entry, string) {
var l *logrus.Entry
var err error
if args[0] == nil {
@@ -317,7 +268,7 @@ func parseArgs(args []any) (*logrus.Entry, string) {
return l, ""
}
-func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry {
+func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry {
for i := 0; i < len(keyValuePairs); i += 2 {
switch name := keyValuePairs[i].(type) {
case error:
@@ -344,7 +295,7 @@ func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry {
return logger
}
-func extractLogger(ctx any) (*logrus.Entry, error) {
+func extractLogger(ctx interface{}) (*logrus.Entry, error) {
switch ctx := ctx.(type) {
case *logrus.Entry:
return ctx, nil
@@ -363,8 +314,6 @@ func extractLogger(ctx any) (*logrus.Entry, error) {
func createNewLogger() *logrus.Entry {
//logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true})
//l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}
- loggerMu.RLock()
- defer loggerMu.RUnlock()
logger := logrus.NewEntry(defaultLogger)
return logger
}
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/main.go b/main.go
index b5fb508b4..65db162ac 100644
--- a/main.go
+++ b/main.go
@@ -9,12 +9,11 @@ import (
//goland:noinspection GoBoolExpressions
func main() {
- // These references force the inclusion of build tags when compiling the project.
+ // This import is used to force the inclusion of the `netgo` tag when compiling the project.
// If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify
- // the required build tags when compiling the project.
+ // the `netgo` build tag when compiling the project.
// To avoid these kind of errors, you should use `make build` to compile the project.
_ = buildtags.NETGO
- _ = buildtags.SQLITE_FTS5
cmd.Execute()
}
diff --git a/model/album.go b/model/album.go
index a43195419..a8dcfe682 100644
--- a/model/album.go
+++ b/model/album.go
@@ -1,20 +1,16 @@
package model
import (
- "context"
"iter"
"math"
"sync"
"time"
- "github.com/deluan/rest"
"github.com/gohugoio/hashstructure"
- "github.com/navidrome/navidrome/conf"
)
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 +46,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"`
@@ -76,13 +70,6 @@ func (a Album) CoverArtID() ArtworkID {
return artworkIDFromAlbum(a)
}
-func (a Album) FullName() string {
- if v := a.Tags.First(TagAlbumVersion); conf.Server.Subsonic.AppendAlbumVersion && v != "" {
- return appendSuffix(a.Name, v)
- }
- return a.Name
-}
-
// Equals compares two Album structs, ignoring calculated fields
func (a Album) Equals(other Album) bool {
// Normalize float32 values to avoid false negatives
@@ -138,25 +125,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..a45d16dd5 100644
--- a/model/album_test.go
+++ b/model/album_test.go
@@ -3,32 +3,11 @@ package model_test
import (
"encoding/json"
- "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("Album", func() {
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- })
- DescribeTable("FullName",
- func(enabled bool, tags Tags, expected string) {
- conf.Server.Subsonic.AppendAlbumVersion = enabled
- a := Album{Name: "Album", Tags: tags}
- Expect(a.FullName()).To(Equal(expected))
- },
- Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album (Remastered)"),
- 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]"),
- )
-})
-
var _ = Describe("Albums", func() {
var albums Albums
diff --git a/model/annotation.go b/model/annotation.go
index 64b8ddc17..2ec72c1b7 100644
--- a/model/annotation.go
+++ b/model/annotation.go
@@ -1,23 +1,18 @@
package model
-import (
- "context"
- "time"
-)
+import "time"
type Annotations struct {
- PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
- PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
- Rating int `structs:"rating" json:"rating,omitempty" `
- RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
- Starred bool `structs:"starred" json:"starred,omitempty" `
- StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
- AverageRating float64 `structs:"average_rating" json:"averageRating,omitempty"`
+ PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
+ PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
+ Rating int `structs:"rating" json:"rating,omitempty" `
+ Starred bool `structs:"starred" json:"starred,omitempty" `
+ StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
}
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..309ee800f 100644
--- a/model/artist.go
+++ b/model/artist.go
@@ -1,19 +1,13 @@
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"`
@@ -40,8 +34,6 @@ type Artist struct {
Missing bool `structs:"missing" json:"missing"`
- UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
-
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
}
@@ -66,10 +58,6 @@ func (a Artist) CoverArtID() ArtworkID {
return artworkIDFromArtist(a)
}
-func (a Artist) UploadedImagePath() string {
- return UploadedImagePath(consts.EntityArtist, a.UploadedImage)
-}
-
// Roles returns the roles this artist has participated in., based on the Stats field
func (a Artist) Roles() []Role {
return slices.Collect(maps.Keys(a.Stats))
@@ -83,22 +71,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
deleted file mode 100644
index db897d3d5..000000000
--- a/model/artist_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package model_test
-
-import (
- "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("Artist", func() {
- Describe("UploadedImagePath", func() {
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.DataFolder = conf.NewDir("/data")
- })
-
- It("returns empty string when no image uploaded", func() {
- a := model.Artist{ID: "ar-1"}
- Expect(a.UploadedImagePath()).To(BeEmpty())
- })
-
- It("returns full path when image is set", func() {
- a := model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"}
- Expect(a.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "artist", "ar-1_test.jpg")))
- })
- })
-})
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..36026dd03 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,18 +17,11 @@ 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"}
KindAlbumArtwork = Kind{"al", "album"}
KindPlaylistArtwork = Kind{"pl", "playlist"}
- KindDiscArtwork = Kind{"dc", "disc"}
- KindRadioArtwork = Kind{"ra", "radio"}
)
var artworkKindMap = map[string]Kind{
@@ -38,27 +29,12 @@ var artworkKindMap = map[string]Kind{
KindArtistArtwork.prefix: KindArtistArtwork,
KindAlbumArtwork.prefix: KindAlbumArtwork,
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
- KindDiscArtwork.prefix: KindDiscArtwork,
- 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 +42,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 +71,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 {
@@ -131,38 +91,33 @@ func MustParseArtworkID(id string) ArtworkID {
return artID
}
-func DiscArtworkID(albumID string, discNumber int) string {
- return fmt.Sprintf("%s:%d", albumID, discNumber)
-}
-
-func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) {
- parts := strings.SplitN(id, ":", 2)
- if len(parts) != 2 || parts[1] == "" {
- return "", 0, errors.New("invalid disc artwork id")
- }
- num, err := strconv.Atoi(parts[1])
- if err != nil {
- return "", 0, fmt.Errorf("invalid disc number in artwork id: %w", err)
- }
- return parts[0], num, nil
-}
-
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}
-}
-
-func artworkIDFromRadio(r Radio) ArtworkID {
- return ArtworkID{Kind: KindRadioArtwork, ID: r.ID, Hash: r.ImageHash}
+ return ArtworkID{
+ Kind: KindArtistArtwork,
+ ID: ar.ID,
+ }
}
diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go
index af6a12ffb..2f42217f9 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,48 +25,15 @@ 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)
- parsedId, err := model.ParseArtworkID(id.String())
- Expect(err).ToNot(HaveOccurred())
- Expect(parsedId.Kind).To(Equal(model.KindDiscArtwork))
- Expect(parsedId.ID).To(Equal("albumid123:2"))
- })
- })
-
- Describe("ParseDiscArtworkID", func() {
- DescribeTable("parses composite disc artwork IDs",
- func(id string, expectedAlbum string, expectedDisc int, expectErr bool) {
- albumID, discNumber, err := model.ParseDiscArtworkID(id)
- if expectErr {
- Expect(err).To(HaveOccurred())
- } else {
- Expect(err).ToNot(HaveOccurred())
- Expect(albumID).To(Equal(expectedAlbum))
- Expect(discNumber).To(Equal(expectedDisc))
- }
- },
- Entry("valid id", "albumid123:2", "albumid123", 2, false),
- Entry("disc number 1", "abc:1", "abc", 1, false),
- Entry("large disc number", "abc:10", "abc", 10, false),
- Entry("missing colon", "albumid123", "", 0, true),
- Entry("missing disc number", "albumid123:", "", 0, true),
- Entry("non-numeric disc", "albumid123:abc", "", 0, true),
- Entry("empty string", "", "", 0, true),
- )
- })
-
Describe("ParseArtworkID()", func() {
It("parses album artwork ids", func() {
id, err := model.ParseArtworkID("al-1234")
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 +41,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..54ac59697 100644
--- a/model/criteria/criteria.go
+++ b/model/criteria/criteria.go
@@ -1,105 +1,125 @@
-// Package criteria implements the smart playlist criteria DSL.
+// Package criteria implements a Criteria API based on Masterminds/squirrel
package criteria
import (
"encoding/json"
"errors"
"fmt"
- "slices"
- "time"
+ "strings"
+ "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/utils"
)
-type Expression interface {
- fields() map[string]any
-}
+type Expression = squirrel.Sqlizer
type Criteria struct {
Expression
- Sort string
- Order string
- Limit int
- LimitPercent int
- Offset int
- RefreshDelay time.Duration // 0 = use conf.Server.SmartPlaylistRefreshDelay
+ Sort string
+ Order string
+ Limit int
+ Offset int
}
-// EffectiveLimit resolves the effective limit for a query. If a fixed Limit is
-// set it takes precedence. Otherwise, if LimitPercent is set, the limit is
-// computed as a percentage of totalCount (minimum 1 when totalCount > 0).
-// Returns 0 when no limit applies.
-func (c Criteria) EffectiveLimit(totalCount int64) int {
- if c.Limit > 0 {
- return c.Limit
+func (c Criteria) OrderBy() string {
+ if c.Sort == "" {
+ c.Sort = "title"
}
- if c.LimitPercent > 0 && c.LimitPercent <= 100 {
- if totalCount <= 0 {
- return 0
+
+ order := strings.ToLower(strings.TrimSpace(c.Order))
+ if order != "" && order != "asc" && order != "desc" {
+ log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
+ order = ""
+ }
+
+ parts := strings.Split(c.Sort, ",")
+ fields := make([]string, 0, len(parts))
+
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
}
- result := int(totalCount) * c.LimitPercent / 100
- if result < 1 {
- return 1
+
+ dir := "asc"
+ if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") {
+ if strings.HasPrefix(p, "-") {
+ dir = "desc"
+ }
+ p = strings.TrimSpace(p[1:])
}
- return result
+
+ sortField := strings.ToLower(p)
+ f := fieldMap[sortField]
+ if f == nil {
+ log.Error("Invalid field in 'sort' field", "sort", sortField)
+ continue
+ }
+
+ var mapped string
+
+ if f.order != "" {
+ mapped = f.order
+ } else if f.isTag {
+ // Use the actual field name (handles aliases like albumtype -> releasetype)
+ tagName := sortField
+ if f.field != "" {
+ tagName = f.field
+ }
+ mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')"
+ } else if f.isRole {
+ mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
+ } else {
+ mapped = f.field
+ }
+ if f.numeric {
+ mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
+ }
+ // If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction.
+ // This ensures that the global order applies consistently across all fields.
+ if order == "desc" {
+ if dir == "asc" {
+ dir = "desc"
+ } else {
+ dir = "asc"
+ }
+ }
+
+ fields = append(fields, mapped+" "+dir)
}
- return 0
+
+ return strings.Join(fields, ", ")
}
-// ResolveLimit converts a percentage-based limit into an absolute Limit using
-// the given totalCount. It is a no-op when a fixed Limit is already set or when
-// no percentage limit is configured.
-func (c *Criteria) ResolveLimit(totalCount int64) {
- if !c.IsPercentageLimit() {
- return
- }
- c.Limit = c.EffectiveLimit(totalCount)
-}
-
-// IsPercentageLimit returns true when the criteria uses a valid percentage-based
-// limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it).
-func (c Criteria) IsPercentageLimit() bool {
- return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100
+func (c Criteria) ToSql() (sql string, args []any, err error) {
+ return c.Expression.ToSql()
}
func (c Criteria) ChildPlaylistIds() []string {
- return c.childPlaylistRefs(conjunction.ChildPlaylistIds)
-}
-
-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 {
+ if c.Expression == nil {
return nil
}
- refs := extract(parent)
- slices.Sort(refs)
- return slices.Compact(refs)
+
+ if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil {
+ return parent.ChildPlaylistIds()
+ }
+
+ return nil
}
func (c Criteria) MarshalJSON() ([]byte, error) {
aux := struct {
- All []Expression `json:"all,omitempty"`
- Any []Expression `json:"any,omitempty"`
- Sort string `json:"sort,omitempty"`
- Order string `json:"order,omitempty"`
- Limit int `json:"limit,omitempty"`
- LimitPercent int `json:"limitPercent,omitempty"`
- Offset int `json:"offset,omitempty"`
- RefreshDelay string `json:"refreshDelay,omitempty"`
+ All []Expression `json:"all,omitempty"`
+ Any []Expression `json:"any,omitempty"`
+ Sort string `json:"sort,omitempty"`
+ Order string `json:"order,omitempty"`
+ Limit int `json:"limit,omitempty"`
+ Offset int `json:"offset,omitempty"`
}{
- Sort: c.Sort,
- Order: c.Order,
- Limit: c.Limit,
- LimitPercent: c.LimitPercent,
- Offset: c.Offset,
- }
- if c.RefreshDelay > 0 {
- aux.RefreshDelay = utils.FormatDuration(c.RefreshDelay)
+ Sort: c.Sort,
+ Order: c.Order,
+ Limit: c.Limit,
+ Offset: c.Offset,
}
switch rules := c.Expression.(type) {
case Any:
@@ -114,27 +134,20 @@ 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"`
+ 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')")
}
@@ -142,23 +155,5 @@ func (c *Criteria) UnmarshalJSON(data []byte) error {
c.Order = aux.Order
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)
- aux.LimitPercent = 0
- } else if aux.LimitPercent > 100 {
- log.Warn("limitPercent value out of range, clamping to 100", "value", aux.LimitPercent)
- aux.LimitPercent = 100
- }
- c.LimitPercent = aux.LimitPercent
return nil
}
diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go
index de5124568..032ead5c8 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"
@@ -28,7 +27,6 @@ var _ = Describe("Criteria", func() {
StartsWith{"comment": "this"},
InTheRange{"year": []int{1980, 1990}},
IsNot{"genre": "Rock"},
- Gt{"albumrating": 3},
},
},
Sort: "title",
@@ -50,8 +48,7 @@ var _ = Describe("Criteria", func() {
{ "all": [
{ "startsWith": {"comment": "this"} },
{ "inTheRange": {"year":[1980,1990]} },
- { "isNot": { "genre": "Rock" }},
- { "gt": { "albumrating": 3 } }
+ { "isNot": { "genre": "Rock" }}
]
}
],
@@ -66,6 +63,16 @@ var _ = Describe("Criteria", func() {
}
jsonObj = b.String()
})
+ It("generates valid SQL", func() {
+ sql, args, err := goObj.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal(
+ `(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
+ `AND (not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) ` +
+ `OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
+ `AND not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)))`))
+ gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock"))
+ })
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
@@ -79,267 +86,114 @@ var _ = Describe("Criteria", func() {
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
- })
-
- 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() {
- goObj := Criteria{
- Expression: All{Contains{"title": "love"}},
- Sort: "title",
- Order: "asc",
- LimitPercent: 10,
- }
- j, err := json.Marshal(goObj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limitPercent":10`))
- gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`"limit"`))
-
- var newObj Criteria
- err = json.Unmarshal(j, &newObj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(newObj.LimitPercent).To(gomega.Equal(10))
- gomega.Expect(newObj.Limit).To(gomega.Equal(0))
+ Describe("OrderBy", func() {
+ It("sorts by regular fields", func() {
+ gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc"))
})
- It("does not include limitPercent when zero", func() {
- goObj := Criteria{
- Expression: All{Contains{"title": "love"}},
- Limit: 50,
- }
- j, err := json.Marshal(goObj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limit":50`))
- gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`limitPercent`))
+ It("sorts by tag fields", func() {
+ goObj.Sort = "genre"
+ gomega.Expect(goObj.OrderBy()).To(
+ gomega.Equal(
+ "COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc",
+ ),
+ )
})
- It("backward compatible: JSON with only limit still works", func() {
- jsonStr := `{"all":[{"contains":{"title":"love"}}],"limit":20}`
- var c Criteria
- err := json.Unmarshal([]byte(jsonStr), &c)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(c.Limit).To(gomega.Equal(20))
- gomega.Expect(c.LimitPercent).To(gomega.Equal(0))
- })
- })
-
- Describe("UnmarshalJSON clamping", func() {
- It("clamps values above 100 to 100", func() {
- jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":150}`
- var c Criteria
- err := json.Unmarshal([]byte(jsonStr), &c)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(c.LimitPercent).To(gomega.Equal(100))
+ It("sorts by role fields", func() {
+ goObj.Sort = "artist"
+ gomega.Expect(goObj.OrderBy()).To(
+ gomega.Equal(
+ "COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc",
+ ),
+ )
})
- It("clamps negative values to 0", func() {
- jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":-5}`
- var c Criteria
- err := json.Unmarshal([]byte(jsonStr), &c)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(c.LimitPercent).To(gomega.Equal(0))
- })
- })
-
- Describe("EffectiveLimit", func() {
- It("returns fixed limit when Limit is set", func() {
- c := Criteria{Limit: 50, LimitPercent: 10}
- gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(50))
+ It("casts numeric tags when sorting", func() {
+ AddTagNames([]string{"rate"})
+ AddNumericTags([]string{"rate"})
+ goObj.Sort = "rate"
+ gomega.Expect(goObj.OrderBy()).To(
+ gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
+ )
})
- It("returns percentage-based limit", func() {
- c := Criteria{LimitPercent: 10}
- gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(45))
+ It("sorts by albumtype alias (resolves to releasetype)", func() {
+ AddTagNames([]string{"releasetype"})
+ goObj.Sort = "albumtype"
+ gomega.Expect(goObj.OrderBy()).To(
+ gomega.Equal(
+ "COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc",
+ ),
+ )
})
- It("returns minimum 1 when totalCount > 0 and percentage rounds to 0", func() {
- c := Criteria{LimitPercent: 1}
- gomega.Expect(c.EffectiveLimit(5)).To(gomega.Equal(1))
+ It("sorts by random", func() {
+ newObj := goObj
+ newObj.Sort = "random"
+ gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
})
- It("returns 0 when totalCount is 0", func() {
- c := Criteria{LimitPercent: 10}
- gomega.Expect(c.EffectiveLimit(0)).To(gomega.Equal(0))
+ It("sorts by multiple fields", func() {
+ goObj.Sort = "title,-rating"
+ gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
+ "media_file.title asc, COALESCE(annotation.rating, 0) desc",
+ ))
})
- It("returns 0 when no limit is set", func() {
- c := Criteria{}
- gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(0))
+ It("reverts order when order is desc", func() {
+ goObj.Sort = "-date,artist"
+ goObj.Order = "desc"
+ gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
+ "media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc",
+ ))
})
- It("returns full count for 100%", func() {
- c := Criteria{LimitPercent: 100}
- gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(450))
- })
-
- It("returns 1 for 1% of 50 items", func() {
- c := Criteria{LimitPercent: 1}
- gomega.Expect(c.EffectiveLimit(50)).To(gomega.Equal(1))
- })
- })
-
- Describe("ResolveLimit", func() {
- It("resolves percentage to absolute limit preserving LimitPercent", func() {
- c := Criteria{LimitPercent: 10}
- c.ResolveLimit(450)
- gomega.Expect(c.Limit).To(gomega.Equal(45))
- })
-
- It("does nothing when Limit is already set", func() {
- c := Criteria{Limit: 50, LimitPercent: 10}
- c.ResolveLimit(1000)
- gomega.Expect(c.Limit).To(gomega.Equal(50))
- })
-
- It("does nothing when no limit is configured", func() {
- c := Criteria{}
- c.ResolveLimit(1000)
- gomega.Expect(c.Limit).To(gomega.Equal(0))
- })
-
- It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() {
- c := Criteria{LimitPercent: 1}
- c.ResolveLimit(5)
- gomega.Expect(c.Limit).To(gomega.Equal(1))
- })
-
- It("is idempotent when called twice", func() {
- c := Criteria{LimitPercent: 10}
- c.ResolveLimit(450)
- c.ResolveLimit(450)
- gomega.Expect(c.Limit).To(gomega.Equal(45))
- })
- })
-
- Describe("IsPercentageLimit", func() {
- It("returns true when LimitPercent is set and Limit is 0", func() {
- c := Criteria{LimitPercent: 10}
- gomega.Expect(c.IsPercentageLimit()).To(gomega.BeTrue())
- })
-
- It("returns false when Limit is set", func() {
- c := Criteria{Limit: 50, LimitPercent: 10}
- gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
- })
-
- It("returns false when neither is set", func() {
- c := Criteria{}
- gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
- })
-
- It("returns false when LimitPercent is out of range", func() {
- c := Criteria{LimitPercent: 150}
- gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse())
+ It("ignores invalid sort fields", func() {
+ goObj.Sort = "bogus,title"
+ gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
+ "media_file.title asc",
+ ))
})
})
})
- 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,
+ Context("with artist roles", func() {
+ BeforeEach(func() {
+ goObj = Criteria{
+ Expression: All{
+ Is{"artist": "The Beatles"},
+ Contains{"composer": "Lennon"},
+ },
}
- 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)
+ It("generates valid SQL", func() {
+ sql, args, err := goObj.ToSql()
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))
+ gomega.Expect(sql).To(gomega.Equal(
+ `(exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) AND ` +
+ `exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`,
+ ))
+ gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
})
})
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 +201,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 +217,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{
@@ -404,23 +244,5 @@ var _ = Describe("Criteria", func() {
ids := Criteria{}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
- It("returns empty list for leaf expressions", func() {
- ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds()
- gomega.Expect(ids).To(gomega.BeEmpty())
- })
- It("deduplicates repeated playlist IDs", func() {
- sharedID := uuid.NewString()
- goObj = Criteria{
- Expression: All{
- InPlaylist{"id": sharedID},
- Any{
- InPlaylist{"id": sharedID},
- NotInPlaylist{"id": sharedID},
- },
- },
- }
- ids := goObj.ChildPlaylistIds()
- gomega.Expect(ids).To(gomega.Equal([]string{sharedID}))
- })
})
})
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..70719cd6f 100644
--- a/model/criteria/fields.go
+++ b/model/criteria/fields.go
@@ -1,180 +1,242 @@
package criteria
-import "strings"
+import (
+ "fmt"
+ "reflect"
+ "strings"
-// FieldInfo contains semantic metadata about a criteria field.
-type FieldInfo struct {
- Alias string // If set, this field is a backward-compat alias for another canonical name
- IsTag bool
- 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
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/log"
+)
- tagAlias string // If set, a tag name from mappings.yaml that resolves to this field
- name string // Canonical name, populated by LookupField from the map key
+var fieldMap = map[string]*mappedField{
+ "title": {field: "media_file.title"},
+ "album": {field: "media_file.album"},
+ "hascoverart": {field: "media_file.has_cover_art"},
+ "tracknumber": {field: "media_file.track_number"},
+ "discnumber": {field: "media_file.disc_number"},
+ "year": {field: "media_file.year"},
+ "date": {field: "media_file.date", alias: "recordingdate"},
+ "originalyear": {field: "media_file.original_year"},
+ "originaldate": {field: "media_file.original_date"},
+ "releaseyear": {field: "media_file.release_year"},
+ "releasedate": {field: "media_file.release_date"},
+ "size": {field: "media_file.size"},
+ "compilation": {field: "media_file.compilation"},
+ "dateadded": {field: "media_file.created_at"},
+ "datemodified": {field: "media_file.updated_at"},
+ "discsubtitle": {field: "media_file.disc_subtitle"},
+ "comment": {field: "media_file.comment"},
+ "lyrics": {field: "media_file.lyrics"},
+ "sorttitle": {field: "media_file.sort_title"},
+ "sortalbum": {field: "media_file.sort_album_name"},
+ "sortartist": {field: "media_file.sort_artist_name"},
+ "sortalbumartist": {field: "media_file.sort_album_artist_name"},
+ "albumcomment": {field: "media_file.mbz_album_comment"},
+ "catalognumber": {field: "media_file.catalog_num"},
+ "filepath": {field: "media_file.path"},
+ "filetype": {field: "media_file.suffix"},
+ "duration": {field: "media_file.duration"},
+ "bitrate": {field: "media_file.bit_rate"},
+ "bitdepth": {field: "media_file.bit_depth"},
+ "bpm": {field: "media_file.bpm"},
+ "channels": {field: "media_file.channels"},
+ "loved": {field: "COALESCE(annotation.starred, false)"},
+ "dateloved": {field: "annotation.starred_at"},
+ "lastplayed": {field: "annotation.play_date"},
+ "playcount": {field: "COALESCE(annotation.play_count, 0)"},
+ "rating": {field: "COALESCE(annotation.rating, 0)"},
+ "mbz_album_id": {field: "media_file.mbz_album_id"},
+ "mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
+ "mbz_artist_id": {field: "media_file.mbz_artist_id"},
+ "mbz_recording_id": {field: "media_file.mbz_recording_id"},
+ "mbz_release_track_id": {field: "media_file.mbz_release_track_id"},
+ "mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
+ "library_id": {field: "media_file.library_id", numeric: true},
+
+ // Backward compatibility: albumtype is an alias for releasetype tag
+ "albumtype": {field: "releasetype", isTag: true},
+
+ // special fields
+ "random": {field: "", order: "random()"}, // pseudo-field for random sorting
+ "value": {field: "value"}, // pseudo-field for tag and roles values
}
-// Name returns the canonical field name (the map key used to register this field).
-func (f FieldInfo) Name() string {
- return f.name
+type mappedField struct {
+ field string
+ order string
+ isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
+ isTag bool // true if the field is a tag imported from the file metadata
+ alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
+ numeric bool // true if the field/tag should be treated as numeric
}
-var fieldMap = map[string]FieldInfo{
- "title": {},
- "album": {Nullable: true},
- "hascoverart": {Boolean: true},
- "tracknumber": {},
- "discnumber": {},
- "year": {},
- "date": {tagAlias: "recordingdate"},
- "originalyear": {},
- "originaldate": {},
- "releaseyear": {},
- "releasedate": {},
- "size": {},
- "compilation": {Boolean: true},
- "missing": {Boolean: true},
- "explicitstatus": {Nullable: true},
- "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},
- "filepath": {},
- "filetype": {},
- "codec": {},
- "duration": {},
- "bitrate": {},
- "bitdepth": {Numeric: true, Nullable: true},
- "samplerate": {},
- "bpm": {Numeric: true, Nullable: true},
- "channels": {},
- "loved": {Boolean: true},
- "dateloved": {},
- "lastplayed": {},
- "daterated": {},
- "playcount": {},
- "rating": {},
- "averagerating": {Numeric: true},
- "albumrating": {},
- "albumloved": {Boolean: true},
- "albumplaycount": {},
- "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},
- "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": {},
-}
-
-// AllFieldNames returns the names of all registered criteria fields.
-func AllFieldNames() []string {
- names := make([]string, 0, len(fieldMap))
- for name := range fieldMap {
- names = append(names, name)
- }
- return names
-}
-
-// LookupField returns semantic metadata for a criteria field name.
-func LookupField(name string) (FieldInfo, bool) {
- key := strings.ToLower(name)
- f, ok := fieldMap[key]
- if ok {
- if f.Alias != "" {
- f.name = f.Alias
+func mapFields(expr map[string]any) map[string]any {
+ m := make(map[string]any)
+ for f, v := range expr {
+ if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" {
+ m[dbf.field] = v
} else {
- f.name = key
+ log.Error("Invalid field in criteria", "field", f)
}
}
- return f, ok
+ return m
+}
+
+// mapExpr maps a normal field expression to a specific type of expression (tag or role).
+// This is required because tags are handled differently than other fields,
+// as they are stored as a JSON column in the database.
+func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer {
+ rv := reflect.ValueOf(expr)
+ if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String {
+ log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr))
+ }
+
+ // Extract into a generic map
+ var k string
+ m := make(map[string]any, rv.Len())
+ for _, key := range rv.MapKeys() {
+ // Save the key to build the expression, and use the provided keyName as the key
+ k = key.String()
+ m["value"] = rv.MapIndex(key).Interface()
+ break // only one key is expected (and supported)
+ }
+
+ // Clear the original map
+ for _, key := range rv.MapKeys() {
+ rv.SetMapIndex(key, reflect.Value{})
+ }
+
+ // Write the updated map back into the original variable
+ for key, val := range m {
+ rv.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(val))
+ }
+
+ return exprFunc(k, expr, negate)
+}
+
+// mapTagExpr maps a normal field expression to a tag expression.
+func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
+ return mapExpr(expr, negate, tagExpr)
+}
+
+// mapRoleExpr maps a normal field expression to an artist role expression.
+func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
+ return mapExpr(expr, negate, roleExpr)
+}
+
+func isTagExpr(expr map[string]any) bool {
+ for f := range expr {
+ if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag {
+ return true
+ }
+ }
+ return false
+}
+
+func isRoleExpr(expr map[string]any) bool {
+ for f := range expr {
+ if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole {
+ return true
+ }
+ }
+ return false
+}
+
+func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
+ return tagCond{tag: tag, cond: cond, not: negate}
+}
+
+type tagCond struct {
+ tag string
+ cond squirrel.Sqlizer
+ not bool
+}
+
+func (e tagCond) ToSql() (string, []any, error) {
+ cond, args, err := e.cond.ToSql()
+
+ // Resolve the actual tag name (handles aliases like albumtype -> releasetype)
+ tagName := e.tag
+ if fm, ok := fieldMap[e.tag]; ok {
+ if fm.field != "" {
+ tagName = fm.field
+ }
+ if fm.numeric {
+ cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
+ }
+ }
+
+ cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
+ tagName, cond)
+ if e.not {
+ cond = "not " + cond
+ }
+ return cond, args, err
+}
+
+func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
+ return roleCond{role: role, cond: cond, not: negate}
+}
+
+type roleCond struct {
+ role string
+ cond squirrel.Sqlizer
+ not bool
+}
+
+func (e roleCond) ToSql() (string, []any, error) {
+ cond, args, err := e.cond.ToSql()
+ cond = fmt.Sprintf(`exists (select 1 from json_tree(participants, '$.%s') where key='name' and %s)`,
+ e.role, cond)
+ if e.not {
+ cond = "not " + cond
+ }
+ return cond, args, err
}
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
-// smart playlists.
+// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
func AddRoles(roles []string) {
for _, role := range roles {
name := strings.ToLower(role)
if _, ok := fieldMap[name]; ok {
continue
}
- fieldMap[name] = FieldInfo{IsRole: true}
+ fieldMap[name] = &mappedField{field: name, isRole: true}
}
}
-// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yaml`
-// configuration file.
+// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml`
+// file to the field map, so they can be used in smart playlists.
+// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent.
func AddTagNames(tagNames []string) {
- for _, tagName := range tagNames {
- name := strings.ToLower(tagName)
+ for _, name := range tagNames {
+ name := strings.ToLower(name)
if _, ok := fieldMap[name]; ok {
continue
}
- for key, fm := range fieldMap {
- if fm.tagAlias == name {
- fm.Alias = key
- fm.tagAlias = ""
+ for _, fm := range fieldMap {
+ if fm.alias == name {
fieldMap[name] = fm
break
}
}
if _, ok := fieldMap[name]; !ok {
- fieldMap[name] = FieldInfo{IsTag: true}
+ fieldMap[name] = &mappedField{field: name, isTag: true}
}
}
}
-// AddNumericTags adds tags that should be treated as numbers.
+// AddNumericTags marks the given tag names as numeric so they can be cast
+// when used in comparisons or sorting.
func AddNumericTags(tagNames []string) {
- for _, tagName := range tagNames {
- name := strings.ToLower(tagName)
+ for _, name := range tagNames {
+ name := strings.ToLower(name)
if fm, ok := fieldMap[name]; ok {
- fm.Numeric = true
- fieldMap[name] = fm
+ fm.numeric = true
} else {
- fieldMap[name] = FieldInfo{IsTag: true, Numeric: true}
+ fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
}
}
}
diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go
index 2367101a8..accdebd3d 100644
--- a/model/criteria/fields_test.go
+++ b/model/criteria/fields_test.go
@@ -6,86 +6,11 @@ import (
)
var _ = Describe("fields", func() {
- Describe("LookupField", func() {
- It("finds built-in fields case-insensitively", func() {
- field, ok := LookupField("Title")
-
- gomega.Expect(ok).To(gomega.BeTrue())
- gomega.Expect(field.Name()).To(gomega.Equal("title"))
+ Describe("mapFields", func() {
+ It("ignores random fields", func() {
+ m := map[string]any{"random": "123"}
+ m = mapFields(m)
+ gomega.Expect(m).To(gomega.BeEmpty())
})
-
- It("resolves aliases to their canonical field name", func() {
- field, ok := LookupField("albumtype")
-
- gomega.Expect(ok).To(gomega.BeTrue())
- gomega.Expect(field.Name()).To(gomega.Equal("releasetype"))
- gomega.Expect(field.IsTag).To(gomega.BeTrue())
- })
-
- It("finds registered tag names", func() {
- AddTagNames([]string{"task3_mood"})
-
- field, ok := LookupField("task3_mood")
-
- gomega.Expect(ok).To(gomega.BeTrue())
- gomega.Expect(field.Name()).To(gomega.Equal("task3_mood"))
- gomega.Expect(field.IsTag).To(gomega.BeTrue())
- })
-
- It("marks registered numeric tags", func() {
- AddTagNames([]string{"task3_score"})
- AddNumericTags([]string{"task3_score"})
-
- field, ok := LookupField("task3_score")
-
- gomega.Expect(ok).To(gomega.BeTrue())
- gomega.Expect(field.IsTag).To(gomega.BeTrue())
- gomega.Expect(field.Numeric).To(gomega.BeTrue())
- })
-
- It("finds registered roles", func() {
- AddRoles([]string{"task3_producer"})
-
- field, ok := LookupField("task3_producer")
-
- gomega.Expect(ok).To(gomega.BeTrue())
- gomega.Expect(field.Name()).To(gomega.Equal("task3_producer"))
- 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..f6ab56eda 100644
--- a/model/criteria/json.go
+++ b/model/criteria/json.go
@@ -3,7 +3,6 @@ package criteria
import (
"encoding/json"
"fmt"
- "strconv"
"strings"
)
@@ -33,27 +32,12 @@ 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)
if err != nil {
return nil
}
- normalizeBoolFields(m)
switch opName {
case "is":
return Is(m)
@@ -85,60 +69,10 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
return InPlaylist(m)
case "notinplaylist":
return NotInPlaylist(m)
- case "ismissing":
- normalizeAllBoolFields(m)
- return IsMissing(m)
- case "ispresent":
- normalizeAllBoolFields(m)
- return IsPresent(m)
}
return nil
}
-func normalizeAllBoolFields(m map[string]any) {
- for k, v := range m {
- m[k] = normalizeBoolValue(v)
- }
-}
-
-func normalizeBoolFields(m map[string]any) {
- for field, value := range m {
- info, ok := LookupField(field)
- if ok && info.Boolean {
- m[field] = normalizeBoolValue(value)
- }
- }
-}
-
-// 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
- }
- return v
-}
-
func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression {
var items unmarshalConjunctionType
err := json.Unmarshal(rawValue, &items)
diff --git a/model/criteria/operators.go b/model/criteria/operators.go
index ec32f2d16..336f914de 100644
--- a/model/criteria/operators.go
+++ b/model/criteria/operators.go
@@ -1,192 +1,353 @@
package criteria
-// Conjunctions need to implement this interface, to allow Criteria to extract child playlist references recursively
-type conjunction interface {
- ChildPlaylistIds() []string
- ChildPlaylistPaths() []string
-}
+import (
+ "errors"
+ "fmt"
+ "reflect"
+ "strconv"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+)
type (
- All []Expression
+ All squirrel.And
And = All
)
-func (All) fields() map[string]any { return nil }
+func (all All) ToSql() (sql string, args []any, err error) {
+ return squirrel.And(all).ToSql()
+}
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
+ Any squirrel.Or
Or = Any
)
-func (Any) fields() map[string]any { return nil }
+func (any Any) ToSql() (sql string, args []any, err error) {
+ return squirrel.Or(any).ToSql()
+}
func (any Any) MarshalJSON() ([]byte, error) {
return marshalConjunction("any", any)
}
-func (any Any) ChildPlaylistIds() []string { return extractPlaylistField(any, "id") }
+func (any Any) ChildPlaylistIds() (ids []string) {
+ return extractPlaylistIds(any)
+}
-func (any Any) ChildPlaylistPaths() []string { return extractPlaylistField(any, "path") }
-
-type Is map[string]any
+type Is squirrel.Eq
type Eq = Is
+func (is Is) ToSql() (sql string, args []any, err error) {
+ if isRoleExpr(is) {
+ return mapRoleExpr(is, false).ToSql()
+ }
+ if isTagExpr(is) {
+ return mapTagExpr(is, false).ToSql()
+ }
+ return squirrel.Eq(mapFields(is)).ToSql()
+}
+
func (is Is) MarshalJSON() ([]byte, error) {
return marshalExpression("is", is)
}
-func (is Is) fields() map[string]any { return is }
+type IsNot squirrel.NotEq
-type IsNot map[string]any
-
-func (isn IsNot) MarshalJSON() ([]byte, error) {
- return marshalExpression("isNot", isn)
+func (in IsNot) ToSql() (sql string, args []any, err error) {
+ if isRoleExpr(in) {
+ return mapRoleExpr(squirrel.Eq(in), true).ToSql()
+ }
+ if isTagExpr(in) {
+ return mapTagExpr(squirrel.Eq(in), true).ToSql()
+ }
+ return squirrel.NotEq(mapFields(in)).ToSql()
}
-func (isn IsNot) fields() map[string]any { return isn }
+func (in IsNot) MarshalJSON() ([]byte, error) {
+ return marshalExpression("isNot", in)
+}
-type Gt map[string]any
+type Gt squirrel.Gt
+
+func (gt Gt) ToSql() (sql string, args []any, err error) {
+ if isTagExpr(gt) {
+ return mapTagExpr(gt, false).ToSql()
+ }
+ return squirrel.Gt(mapFields(gt)).ToSql()
+}
func (gt Gt) MarshalJSON() ([]byte, error) {
return marshalExpression("gt", gt)
}
-func (gt Gt) fields() map[string]any { return gt }
+type Lt squirrel.Lt
-type Lt map[string]any
+func (lt Lt) ToSql() (sql string, args []any, err error) {
+ if isTagExpr(lt) {
+ return mapTagExpr(squirrel.Lt(lt), false).ToSql()
+ }
+ return squirrel.Lt(mapFields(lt)).ToSql()
+}
func (lt Lt) MarshalJSON() ([]byte, error) {
return marshalExpression("lt", lt)
}
-func (lt Lt) fields() map[string]any { return lt }
+type Before squirrel.Lt
-type Before map[string]any
+func (bf Before) ToSql() (sql string, args []any, err error) {
+ return Lt(bf).ToSql()
+}
func (bf Before) MarshalJSON() ([]byte, error) {
return marshalExpression("before", bf)
}
-func (bf Before) fields() map[string]any { return bf }
-
type After Gt
+func (af After) ToSql() (sql string, args []any, err error) {
+ return Gt(af).ToSql()
+}
+
func (af After) MarshalJSON() ([]byte, error) {
return marshalExpression("after", af)
}
-func (af After) fields() map[string]any { return af }
-
type Contains map[string]any
+func (ct Contains) ToSql() (sql string, args []any, err error) {
+ lk := squirrel.Like{}
+ for f, v := range mapFields(ct) {
+ lk[f] = fmt.Sprintf("%%%s%%", v)
+ }
+ if isRoleExpr(ct) {
+ return mapRoleExpr(lk, false).ToSql()
+ }
+ if isTagExpr(ct) {
+ return mapTagExpr(lk, false).ToSql()
+ }
+ return lk.ToSql()
+}
+
func (ct Contains) MarshalJSON() ([]byte, error) {
return marshalExpression("contains", ct)
}
-func (ct Contains) fields() map[string]any { return ct }
-
type NotContains map[string]any
+func (nct NotContains) ToSql() (sql string, args []any, err error) {
+ lk := squirrel.NotLike{}
+ for f, v := range mapFields(nct) {
+ lk[f] = fmt.Sprintf("%%%s%%", v)
+ }
+ if isRoleExpr(nct) {
+ return mapRoleExpr(squirrel.Like(lk), true).ToSql()
+ }
+ if isTagExpr(nct) {
+ return mapTagExpr(squirrel.Like(lk), true).ToSql()
+ }
+ return lk.ToSql()
+}
+
func (nct NotContains) MarshalJSON() ([]byte, error) {
return marshalExpression("notContains", nct)
}
-func (nct NotContains) fields() map[string]any { return nct }
-
type StartsWith map[string]any
+func (sw StartsWith) ToSql() (sql string, args []any, err error) {
+ lk := squirrel.Like{}
+ for f, v := range mapFields(sw) {
+ lk[f] = fmt.Sprintf("%s%%", v)
+ }
+ if isRoleExpr(sw) {
+ return mapRoleExpr(lk, false).ToSql()
+ }
+ if isTagExpr(sw) {
+ return mapTagExpr(lk, false).ToSql()
+ }
+ return lk.ToSql()
+}
+
func (sw StartsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("startsWith", sw)
}
-func (sw StartsWith) fields() map[string]any { return sw }
-
type EndsWith map[string]any
-func (ew EndsWith) MarshalJSON() ([]byte, error) {
- return marshalExpression("endsWith", ew)
+func (sw EndsWith) ToSql() (sql string, args []any, err error) {
+ lk := squirrel.Like{}
+ for f, v := range mapFields(sw) {
+ lk[f] = fmt.Sprintf("%%%s", v)
+ }
+ if isRoleExpr(sw) {
+ return mapRoleExpr(lk, false).ToSql()
+ }
+ if isTagExpr(sw) {
+ return mapTagExpr(lk, false).ToSql()
+ }
+ return lk.ToSql()
}
-func (ew EndsWith) fields() map[string]any { return ew }
+func (sw EndsWith) MarshalJSON() ([]byte, error) {
+ return marshalExpression("endsWith", sw)
+}
type InTheRange map[string]any
+func (itr InTheRange) ToSql() (sql string, args []any, err error) {
+ and := squirrel.And{}
+ for f, v := range mapFields(itr) {
+ s := reflect.ValueOf(v)
+ if s.Kind() != reflect.Slice || s.Len() != 2 {
+ return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v)
+ }
+ and = append(and,
+ squirrel.GtOrEq{f: s.Index(0).Interface()},
+ squirrel.LtOrEq{f: s.Index(1).Interface()},
+ )
+ }
+ return and.ToSql()
+}
+
func (itr InTheRange) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheRange", itr)
}
-func (itr InTheRange) fields() map[string]any { return itr }
-
type InTheLast map[string]any
+func (itl InTheLast) ToSql() (sql string, args []any, err error) {
+ exp, err := inPeriod(itl, false)
+ if err != nil {
+ return "", nil, err
+ }
+ return exp.ToSql()
+}
+
func (itl InTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheLast", itl)
}
-func (itl InTheLast) fields() map[string]any { return itl }
-
type NotInTheLast map[string]any
+func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) {
+ exp, err := inPeriod(nitl, true)
+ if err != nil {
+ return "", nil, err
+ }
+ return exp.ToSql()
+}
+
func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("notInTheLast", nitl)
}
-func (nitl NotInTheLast) fields() map[string]any { return nitl }
+func inPeriod(m map[string]any, negate bool) (Expression, error) {
+ var field string
+ var value any
+ for f, v := range mapFields(m) {
+ field, value = f, v
+ break
+ }
+ str := fmt.Sprintf("%v", value)
+ v, err := strconv.ParseInt(str, 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ firstDate := startOfPeriod(v, time.Now())
+
+ if negate {
+ return Or{
+ squirrel.Lt{field: firstDate},
+ squirrel.Eq{field: nil},
+ }, nil
+ }
+ return squirrel.Gt{field: firstDate}, nil
+}
+
+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) ToSql() (sql string, args []any, err error) {
+ return inList(ipl, false)
+}
+
func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("inPlaylist", ipl)
}
-func (ipl InPlaylist) fields() map[string]any { return ipl }
-
type NotInPlaylist map[string]any
-func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) {
- return marshalExpression("notInPlaylist", nipl)
+func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) {
+ return inList(ipl, true)
}
-func (nipl NotInPlaylist) fields() map[string]any { return nipl }
-
-type IsMissing map[string]any
-
-func (im IsMissing) MarshalJSON() ([]byte, error) {
- return marshalExpression("isMissing", im)
+func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) {
+ return marshalExpression("notInPlaylist", ipl)
}
-func (im IsMissing) fields() map[string]any { return im }
+func inList(m map[string]any, negate bool) (sql string, args []any, err error) {
+ var playlistid string
+ var ok bool
+ if playlistid, ok = m["id"].(string); !ok {
+ return "", nil, errors.New("playlist id not given")
+ }
-type IsPresent map[string]any
+ // Subquery to fetch all media files that are contained in given playlist
+ // Only evaluate playlist if it is public
+ subQuery := squirrel.Select("media_file_id").
+ From("playlist_tracks pl").
+ LeftJoin("playlist on pl.playlist_id = playlist.id").
+ Where(squirrel.And{
+ squirrel.Eq{"pl.playlist_id": playlistid},
+ squirrel.Eq{"playlist.public": 1}})
+ subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
-func (ip IsPresent) MarshalJSON() ([]byte, error) {
- return marshalExpression("isPresent", ip)
+ if err != nil {
+ return "", nil, err
+ }
+ if negate {
+ return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil
+ } else {
+ return "media_file.id IN (" + subQText + ")", subQArgs, nil
+ }
}
-func (ip IsPresent) fields() map[string]any { return ip }
+func extractPlaylistIds(inputRule any) (ids []string) {
+ var id string
+ var ok bool
-func extractPlaylistField(inputRule any, field string) (values []string) {
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..4c1db1303 100644
--- a/model/criteria/operators_test.go
+++ b/model/criteria/operators_test.go
@@ -3,6 +3,7 @@ package criteria_test
import (
"encoding/json"
"fmt"
+ "time"
. "github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2"
@@ -16,6 +17,147 @@ var _ = BeforeSuite(func() {
})
var _ = Describe("Operators", func() {
+ rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local)
+ rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local)
+
+ DescribeTable("ToSQL",
+ func(op Expression, expectedSql string, expectedArgs ...any) {
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal(expectedSql))
+ gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...))
+ },
+ Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
+ Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
+ Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1),
+ Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
+ Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
+ Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1),
+ Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2),
+ Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
+ Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
+ Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
+ Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
+ Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
+ Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
+ Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
+ Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd),
+ Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart),
+ Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
+
+ // InPlaylist and NotInPlaylist are special cases
+ Entry("inPlaylist", 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("notInPlaylist", 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("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
+ Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
+
+ // Tag tests
+ Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
+ Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
+ Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value > ?)", "A"),
+ Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value < ?)", "Z"),
+ Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
+ Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
+ Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
+ Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
+
+ // Artist roles tests
+ Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
+ Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
+ Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
+ Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
+ Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
+ Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
+ )
+
+ // TODO Validate operators that are not valid for each field type.
+ XDescribeTable("ToSQL - Invalid Operators",
+ func(op Expression, expectedError string) {
+ _, _, err := op.ToSql()
+ gomega.Expect(err).To(gomega.MatchError(expectedError))
+ },
+ Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
+ )
+
+ Describe("Custom Tags", func() {
+ It("generates valid SQL", func() {
+ AddTagNames([]string{"mood"})
+ op := EndsWith{"mood": "Soft"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
+ })
+ It("casts numeric comparisons", func() {
+ AddNumericTags([]string{"rate"})
+ op := Lt{"rate": 6}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements(6))
+ })
+ It("skips unknown tag names", func() {
+ op := EndsWith{"unknown": "value"}
+ sql, args, _ := op.ToSql()
+ gomega.Expect(sql).To(gomega.BeEmpty())
+ gomega.Expect(args).To(gomega.BeEmpty())
+ })
+ It("supports releasetype as multi-valued tag", func() {
+ AddTagNames([]string{"releasetype"})
+ op := Contains{"releasetype": "soundtrack"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
+ })
+ It("supports albumtype as alias for releasetype", func() {
+ AddTagNames([]string{"releasetype"})
+ op := Contains{"albumtype": "live"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
+ })
+ It("supports albumtype alias with Is operator", func() {
+ AddTagNames([]string{"releasetype"})
+ op := Is{"albumtype": "album"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ // Should query $.releasetype, not $.albumtype
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("album"))
+ })
+ It("supports albumtype alias with IsNot operator", func() {
+ AddTagNames([]string{"releasetype"})
+ op := IsNot{"albumtype": "compilation"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ // Should query $.releasetype, not $.albumtype
+ gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
+ })
+ })
+
+ Describe("Custom Roles", func() {
+ It("generates valid SQL", func() {
+ AddRoles([]string{"producer"})
+ op := EndsWith{"producer": "Eno"}
+ sql, args, err := op.ToSql()
+ gomega.Expect(err).ToNot(gomega.HaveOccurred())
+ gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(participants, '$.producer') where key='name' and value LIKE ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
+ })
+ It("skips unknown roles", func() {
+ op := Contains{"groupie": "Penny Lane"}
+ sql, args, _ := op.ToSql()
+ gomega.Expect(sql).To(gomega.BeEmpty())
+ gomega.Expect(args).To(gomega.BeEmpty())
+ })
+ })
+
DescribeTable("JSON Marshaling",
func(op Expression, jsonString string) {
obj := And{op}
@@ -31,7 +173,6 @@ var _ = Describe("Operators", func() {
},
Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`),
Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`),
- Entry("is [string does not coerce non-boolean field]", Is{"title": "true"}, `{"is":{"title":"true"}}`),
Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`),
Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`),
Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`),
@@ -47,104 +188,5 @@ var _ = Describe("Operators", func() {
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`),
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`),
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`),
- Entry("isMissing [true]", IsMissing{"genre": true}, `{"isMissing":{"genre":true}}`),
- Entry("isMissing [false]", IsMissing{"genre": false}, `{"isMissing":{"genre":false}}`),
- Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`),
- Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`),
- )
-
- Describe("Boolean string coercion at unmarshal time (issue #4826)", func() {
- It("coerces string 'true' to bool for boolean fields", func() {
- var obj UnmarshalConjunctionType
- err := json.Unmarshal([]byte(`[{"is":{"loved":"true"}}]`), &obj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true}))
- })
-
- It("coerces string 'false' to bool for boolean fields", func() {
- var obj UnmarshalConjunctionType
- err := json.Unmarshal([]byte(`[{"is":{"loved":"false"}}]`), &obj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false}))
- })
-
- It("does not coerce string values for non-boolean fields", func() {
- var obj UnmarshalConjunctionType
- err := json.Unmarshal([]byte(`[{"is":{"title":"true"}}]`), &obj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(obj[0]).To(gomega.Equal(Is{"title": "true"}))
- })
-
- It("coerces numeric 1 to bool true for boolean fields", func() {
- var obj UnmarshalConjunctionType
- err := json.Unmarshal([]byte(`[{"is":{"loved":1}}]`), &obj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true}))
- })
-
- It("coerces numeric 0 to bool false for boolean fields", func() {
- var obj UnmarshalConjunctionType
- err := json.Unmarshal([]byte(`[{"is":{"loved":0}}]`), &obj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false}))
- })
-
- It("coerces in nested any/all groups", func() {
- var c Criteria
- err := json.Unmarshal([]byte(`{"all":[{"contains":{"title":"love"}},{"any":[{"is":{"loved":"true"}}]}]}`), &c)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- all := c.Expression.(All)
- nested := all[1].(Any)
- gomega.Expect(nested[0]).To(gomega.Equal(Is{"loved": true}))
- })
-
- It("coerces isMissing string 'true' to bool", func() {
- var obj UnmarshalConjunctionType
- err := json.Unmarshal([]byte(`[{"isMissing":{"genre":"true"}}]`), &obj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": true}))
- })
-
- It("coerces isMissing numeric 0 to bool false", func() {
- var obj UnmarshalConjunctionType
- err := json.Unmarshal([]byte(`[{"isMissing":{"genre":0}}]`), &obj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": false}))
- })
-
- It("coerces isPresent string 'false' to bool", func() {
- var obj UnmarshalConjunctionType
- err := json.Unmarshal([]byte(`[{"isPresent":{"genre":"false"}}]`), &obj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": false}))
- })
-
- It("coerces isPresent numeric 1 to bool true", func() {
- var obj UnmarshalConjunctionType
- err := json.Unmarshal([]byte(`[{"isPresent":{"genre":1}}]`), &obj)
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- 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/criteria/sort.go b/model/criteria/sort.go
deleted file mode 100644
index e38fe0551..000000000
--- a/model/criteria/sort.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package criteria
-
-import (
- "strings"
-
- "github.com/navidrome/navidrome/log"
-)
-
-type SortField struct {
- Field string
- Desc bool
-}
-
-func (c Criteria) OrderByFields() []SortField {
- sortValue := c.Sort
- if sortValue == "" {
- sortValue = "title"
- }
-
- order := strings.ToLower(strings.TrimSpace(c.Order))
- if order != "" && order != "asc" && order != "desc" {
- log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
- order = ""
- }
-
- parts := strings.Split(sortValue, ",")
- fields := make([]SortField, 0, len(parts))
- for _, part := range parts {
- part = strings.TrimSpace(part)
- if part == "" {
- continue
- }
- desc := false
- if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") {
- desc = strings.HasPrefix(part, "-")
- part = strings.TrimSpace(part[1:])
- }
- info, ok := LookupField(part)
- if !ok {
- log.Error("Invalid field in 'sort' field", "sort", part)
- continue
- }
- if order == "desc" {
- desc = !desc
- }
- fields = append(fields, SortField{Field: info.Name(), Desc: desc})
- }
- if len(fields) == 0 {
- log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue)
- return []SortField{{Field: "title", Desc: false}}
- }
- return fields
-}
-
-func (c Criteria) SortFieldNames() []string {
- sortFields := c.OrderByFields()
- names := make([]string, len(sortFields))
- for i, sf := range sortFields {
- names[i] = sf.Field
- }
- return names
-}
diff --git a/model/criteria/sort_test.go b/model/criteria/sort_test.go
deleted file mode 100644
index 35db88549..000000000
--- a/model/criteria/sort_test.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package criteria
-
-import (
- . "github.com/onsi/ginkgo/v2"
- "github.com/onsi/gomega"
-)
-
-var _ = Describe("OrderByFields", func() {
- It("defaults to title ascending when Sort is empty", func() {
- c := Criteria{}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
- })
-
- It("parses a single field", func() {
- c := Criteria{Sort: "title"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
- })
-
- It("parses descending prefix", func() {
- c := Criteria{Sort: "-rating"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "rating", Desc: true}}))
- })
-
- It("parses ascending prefix", func() {
- c := Criteria{Sort: "+title"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
- })
-
- It("parses multiple comma-separated fields", func() {
- c := Criteria{Sort: "title,-rating"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
- {Field: "title", Desc: false},
- {Field: "rating", Desc: true},
- }))
- })
-
- It("inverts directions when Order is desc", func() {
- c := Criteria{Sort: "-date,title", Order: "desc"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
- {Field: "date", Desc: false},
- {Field: "title", Desc: true},
- }))
- })
-
- It("skips invalid fields", func() {
- c := Criteria{Sort: "bogus,title"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
- })
-
- It("falls back to title when all fields are invalid", func() {
- c := Criteria{Sort: "bogus,invalid"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
- })
-
- It("resolves tag aliases (albumtype -> releasetype)", func() {
- c := Criteria{Sort: "albumtype"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "releasetype", Desc: false}}))
- })
-
- It("resolves field aliases (recordingdate -> date)", func() {
- AddTagNames([]string{"recordingdate"})
- c := Criteria{Sort: "recordingdate"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "date", Desc: false}}))
- })
-
- It("handles the random field", func() {
- c := Criteria{Sort: "random"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "random", Desc: false}}))
- })
-
- It("ignores invalid Order value", func() {
- c := Criteria{Sort: "-title", Order: "invalid"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: true}}))
- })
-
- It("handles whitespace in fields", func() {
- c := Criteria{Sort: " title , -rating "}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
- {Field: "title", Desc: false},
- {Field: "rating", Desc: true},
- }))
- })
-
- It("skips empty parts from trailing commas", func() {
- c := Criteria{Sort: "title,,rating,"}
- gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
- {Field: "title", Desc: false},
- {Field: "rating", Desc: false},
- }))
- })
-})
-
-var _ = Describe("SortFieldNames", func() {
- It("returns canonical field names", func() {
- c := Criteria{Sort: "title,-rating,albumtype"}
- gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title", "rating", "releasetype"}))
- })
-
- It("defaults to title when Sort is empty", func() {
- c := Criteria{}
- gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title"}))
- })
-})
diff --git a/model/criteria/walk.go b/model/criteria/walk.go
deleted file mode 100644
index 7445c2aef..000000000
--- a/model/criteria/walk.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package criteria
-
-import "fmt"
-
-type Visitor func(Expression) error
-
-func Walk(expr Expression, visit Visitor) error {
- if expr == nil {
- return nil
- }
- if err := visit(expr); err != nil {
- return err
- }
- switch e := expr.(type) {
- case All:
- for _, child := range e {
- if err := Walk(child, visit); err != nil {
- return err
- }
- }
- case Any:
- for _, child := range e {
- if err := Walk(child, visit); err != nil {
- return err
- }
- }
- case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist, IsMissing, IsPresent:
- return nil
- default:
- return fmt.Errorf("unknown criteria expression type %T", expr)
- }
- return nil
-}
-
-func Fields(expr Expression) map[string]any {
- return expr.fields()
-}
diff --git a/model/criteria/walk_test.go b/model/criteria/walk_test.go
deleted file mode 100644
index 2e0f12f8d..000000000
--- a/model/criteria/walk_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package criteria
-
-import (
- "fmt"
-
- . "github.com/onsi/ginkgo/v2"
- "github.com/onsi/gomega"
-)
-
-type unknownExpression struct{}
-
-func (unknownExpression) fields() map[string]any { return nil }
-
-var _ = Describe("Walk", func() {
- It("visits the expression tree depth-first", func() {
- expr := All{
- Contains{"title": "love"},
- Any{
- Is{"album": "best of"},
- Gt{"rating": 3},
- },
- }
-
- var visited []string
- err := Walk(expr, func(expr Expression) error {
- visited = append(visited, fmt.Sprintf("%T", expr))
- return nil
- })
-
- gomega.Expect(err).ToNot(gomega.HaveOccurred())
- gomega.Expect(visited).To(gomega.Equal([]string{
- "criteria.All",
- "criteria.Contains",
- "criteria.Any",
- "criteria.Is",
- "criteria.Gt",
- }))
- })
-
- It("stops when the visitor returns an error", func() {
- expectedErr := fmt.Errorf("stop")
-
- err := Walk(All{Contains{"title": "love"}}, func(Expression) error {
- return expectedErr
- })
-
- gomega.Expect(err).To(gomega.MatchError(expectedErr))
- })
-
- It("returns fields for leaf expressions", func() {
- gomega.Expect(Fields(Contains{"title": "love"})).To(gomega.Equal(map[string]any{"title": "love"}))
- gomega.Expect(Fields(After{"date": "2020-01-01"})).To(gomega.Equal(map[string]any{"date": "2020-01-01"}))
- })
-
- It("returns nil fields for group expressions", func() {
- gomega.Expect(Fields(All{Contains{"title": "love"}})).To(gomega.BeNil())
- })
-
- It("returns an error for unknown expression types", func() {
- err := Walk(unknownExpression{}, func(Expression) error { return nil })
-
- gomega.Expect(err).To(gomega.MatchError("unknown criteria expression type criteria.unknownExpression"))
- })
-})
diff --git a/model/datastore.go b/model/datastore.go
index 6ded8c575..4290e2134 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,32 @@ 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
+
+ Resource(ctx context.Context, model interface{}) 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
+ GC(ctx context.Context) 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..f715f8c11 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)
+ GetLastUpdates(lib Library) (map[string]FolderUpdateInfo, error)
+ Put(*Folder) error
+ MarkMissing(missing bool, ids ...string) error
+ GetTouchedWithPlaylists() (FolderCursor, error)
}
diff --git a/model/folder_test.go b/model/folder_test.go
index 4c1b4c2b7..0535f6987 100644
--- a/model/folder_test.go
+++ b/model/folder_test.go
@@ -7,7 +7,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
- "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -67,7 +66,6 @@ var _ = Describe("Folder", func() {
When("the folder has multiple subdirs", func() {
It("should return the correct folder ID", func() {
- tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
folderPath := filepath.FromSlash("/music/rock/metal")
expectedID := id.NewHash("1:rock/metal")
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
@@ -77,7 +75,6 @@ var _ = Describe("Folder", func() {
Describe("NewFolder", func() {
It("should create a new SubFolder with the correct attributes", func() {
- tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
folderPath := filepath.FromSlash("rock/metal")
folder := model.NewFolder(lib, folderPath)
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..f51d8c36a 100644
--- a/model/get_entity.go
+++ b/model/get_entity.go
@@ -2,41 +2,25 @@ 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) }},
+func GetEntityByID(ctx context.Context, ds DataStore, id string) (interface{}, error) {
+ 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
+ }
+ 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..930875260 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"
+
+ gonanoid "github.com/matoous/go-nanoid/v2"
+ "github.com/navidrome/navidrome/log"
)
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 := gonanoid.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
deleted file mode 100644
index 30307fcea..000000000
--- a/model/image.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package model
-
-import (
- "path/filepath"
-
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/consts"
-)
-
-// UploadedImagePath returns the absolute filesystem path for a manually uploaded
-// entity cover image. Returns empty string if filename is empty.
-func UploadedImagePath(entityType, filename string) string {
- if filename == "" {
- return ""
- }
- return filepath.Join(conf.Server.DataFolder.String(), 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", ``, 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 := ``
- 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
-
-
-
-
-
-
-`
-
- 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
-
-
-
-`)
-
- 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(`
-
-
-
-
-`)
-
- 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(`
-
-
-
-
-`)
-
- 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(`
-
-
-
-
-`)
-
- 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
-
-
-
-
-
-`)
-
- 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
-
-
-
-
-
-`)
-
- 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(`
-
-
-
-
-`)
-
- 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(`
-
-
-
-
-`)
-
- 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(`
-
-
-
-
-`)
-
- 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..0ef26d746 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"`
@@ -48,7 +38,7 @@ type MediaFile struct {
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
// AlbumArtist is the display name used for the album artist.
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
- AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
+ AlbumID string `structs:"album_id" json:"albumId"`
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
TrackNumber int `structs:"track_number" json:"trackNumber"`
DiscNumber int `structs:"disc_number" json:"discNumber"`
@@ -64,10 +54,8 @@ 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"`
Genre string `structs:"genre" json:"genre"`
Genres Genres `structs:"-" json:"genres,omitempty"`
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
@@ -81,7 +69,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 +95,12 @@ 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 && mf.Tags[TagSubtitle] != nil {
+ 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)
- }
- 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)
}
@@ -139,27 +110,16 @@ func (mf MediaFile) CoverArtID() ArtworkID {
if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt {
return artworkIDFromMediaFile(mf)
}
- // Otherwise fallback to disc (if available) or album cover
- return mf.DiscCoverArtID()
-}
-
-// DiscCoverArtID returns the disc artwork ID when the media file has a disc number,
-// 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}
- }
+ // if it does not have a coverArt, fallback to the album cover
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 +127,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,8 +139,8 @@ func (mf MediaFile) Hash() string {
ZeroNil: true,
}
hash, _ := hashstructure.Hash(mf, opts)
- sum := xxh3.New()
- sum.Write(fmt.Appendf(nil, "%d", hash))
+ sum := md5.New()
+ sum.Write([]byte(fmt.Sprintf("%d", hash)))
sum.Write(mf.Tags.Hash())
sum.Write(mf.Participants.Hash())
return fmt.Sprintf("%x", sum.Sum(nil))
@@ -256,63 +161,6 @@ func (mf MediaFile) AbsolutePath() string {
return filepath.Join(mf.LibraryPath, mf.Path)
}
-// AudioCodec returns the audio codec for this file.
-// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
-func (mf MediaFile) AudioCodec() string {
- // If we have a stored codec from scanning, normalize and return it
- if mf.Codec != "" {
- return strings.ToLower(mf.Codec)
- }
- // Fallback: infer from Suffix + BitDepth
- return mf.inferCodecFromSuffix()
-}
-
-// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
-func (mf MediaFile) inferCodecFromSuffix() string {
- switch strings.ToLower(mf.Suffix) {
- case "mp3", "mpga":
- return "mp3"
- case "mp2":
- return "mp2"
- case "ogg", "oga":
- return "vorbis"
- case "opus":
- return "opus"
- case "mpc":
- return "mpc"
- case "wma":
- return "wma"
- case "flac":
- return "flac"
- case "wav":
- return "pcm"
- case "aif", "aiff", "aifc":
- return "pcm"
- case "ape":
- return "ape"
- case "wv", "wvp":
- return "wv"
- case "tta":
- return "tta"
- case "tak":
- return "tak"
- case "shn":
- return "shn"
- case "dsf", "dff":
- return "dsd"
- case "m4a":
- // AAC if BitDepth==0, ALAC if BitDepth>0
- if gg.V(mf.BitDepth) > 0 {
- return "alac"
- }
- return "aac"
- case "m4b", "m4p", "m4r":
- return "aac"
- default:
- return ""
- }
-}
-
type MediaFiles []MediaFile
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.
@@ -334,8 +182,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 +212,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 +246,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 +275,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
@@ -470,9 +286,6 @@ func older(t1, t2 time.Time) time.Time {
if t1.IsZero() {
return t2
}
- if t2.IsZero() {
- return t1
- }
if t1.After(t2) {
return t2
}
@@ -539,43 +352,24 @@ 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)
+ Exists(id string) (bool, error)
+ Put(m *MediaFile) error
+ Get(id string) (*MediaFile, error)
+ GetWithParticipants(id string) (*MediaFile, error)
+ GetAll(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..635a61d30 100644
--- a/model/mediafile_test.go
+++ b/model/mediafile_test.go
@@ -6,7 +6,6 @@ import (
"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"
)
@@ -23,7 +22,7 @@ var _ = Describe("MediaFiles", func() {
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName",
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
- MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "music1/file1.mp3", FolderID: "Folder1",
+ MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "/music1/file1.mp3", FolderID: "Folder1",
},
{
ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID",
@@ -31,7 +30,7 @@ var _ = Describe("MediaFiles", func() {
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
MbzReleaseGroupID: "MbzReleaseGroupID",
- Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "music2/file2.mp3", FolderID: "Folder2",
+ Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "/music2/file2.mp3", FolderID: "Folder2",
},
}
})
@@ -52,7 +51,7 @@ var _ = Describe("MediaFiles", func() {
Expect(album.MbzReleaseGroupID).To(Equal("MbzReleaseGroupID"))
Expect(album.CatalogNum).To(Equal("CatalogNum"))
Expect(album.Compilation).To(BeTrue())
- Expect(album.EmbedArtPath).To(Equal("music2/file2.mp3"))
+ Expect(album.EmbedArtPath).To(Equal("/music2/file2.mp3"))
Expect(album.FolderIDs).To(ConsistOf("Folder1", "Folder2"))
})
})
@@ -120,20 +119,6 @@ var _ = Describe("MediaFiles", func() {
Expect(a.MinYear).To(Equal(1999))
})
})
- Context("CreatedAt aggregation", func() {
- It("ignores zero BirthTime values when computing the oldest", func() {
- mfs = MediaFiles{
- {BirthTime: t("2022-12-19 08:30")},
- {BirthTime: time.Time{}},
- {BirthTime: t("2022-12-18 10:00")},
- }
- Expect(mfs.ToAlbum().CreatedAt).To(Equal(t("2022-12-18 10:00")))
- })
- It("returns zero when all BirthTime values are zero", func() {
- mfs = MediaFiles{{BirthTime: time.Time{}}, {BirthTime: time.Time{}}}
- Expect(mfs.ToAlbum().CreatedAt).To(BeZero())
- })
- })
})
When("we have multiple songs with same dates", func() {
BeforeEach(func() {
@@ -218,11 +203,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 +253,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() {
@@ -477,9 +433,6 @@ var _ = Describe("MediaFiles", func() {
DescribeTable("generates correct output",
func(absolutePaths bool, expectedContent string) {
- if absolutePaths {
- tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
- }
result := mfs.ToM3U8("Multi Track", absolutePaths)
Expect(result).To(Equal(expectedContent))
},
@@ -500,7 +453,6 @@ var _ = Describe("MediaFiles", func() {
Context("path variations", func() {
It("handles different path structures", func() {
- tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
mfs = MediaFiles{
{Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"},
{Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"},
@@ -523,64 +475,20 @@ var _ = Describe("MediaFile", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
})
- DescribeTable("FullTitle",
- func(enabled bool, tags Tags, expected string) {
- conf.Server.Subsonic.AppendSubtitle = enabled
- mf := MediaFile{Title: "Song", Tags: tags}
- Expect(mf.FullTitle()).To(Equal(expected))
- },
- Entry("appends subtitle when enabled and tag is present", true, Tags{TagSubtitle: []string{"Live"}}, "Song (Live)"),
- 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) {
- conf.Server.Subsonic.AppendAlbumVersion = enabled
- mf := MediaFile{Album: "Album", Tags: tags}
- Expect(mf.FullAlbumName()).To(Equal(expected))
- },
- Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album (Deluxe Edition)"),
- 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() {
+ Describe(".CoverArtId()", func() {
It("returns its own id if it HasCoverArt", func() {
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
id := mf.CoverArtID()
Expect(id.Kind).To(Equal(KindMediaFileArtwork))
Expect(id.ID).To(Equal(mf.ID))
})
- It("returns disc art id if HasCoverArt is false and DiscNumber > 0", func() {
- mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false, DiscNumber: 2}
- id := mf.CoverArtID()
- Expect(id.Kind).To(Equal(KindDiscArtwork))
- Expect(id.ID).To(Equal("1:2"))
- })
- It("returns its album id if HasCoverArt is false and DiscNumber is 0", func() {
+ It("returns its album id if HasCoverArt is false", func() {
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false}
id := mf.CoverArtID()
Expect(id.Kind).To(Equal(KindAlbumArtwork))
Expect(id.ID).To(Equal(mf.AlbumID))
})
- It("returns disc art id if EnableMediaFileCoverArt is disabled and DiscNumber > 0", func() {
- conf.Server.EnableMediaFileCoverArt = false
- mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 3}
- id := mf.CoverArtID()
- Expect(id.Kind).To(Equal(KindDiscArtwork))
- Expect(id.ID).To(Equal("1:3"))
- })
- It("returns its album id if EnableMediaFileCoverArt is disabled and DiscNumber is 0", func() {
+ It("returns its album id if EnableMediaFileCoverArt is disabled", func() {
conf.Server.EnableMediaFileCoverArt = false
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
id := mf.CoverArtID()
@@ -588,143 +496,6 @@ var _ = Describe("MediaFile", func() {
Expect(id.ID).To(Equal(mf.AlbumID))
})
})
-
- Describe("AudioCodec", func() {
- It("returns normalized stored codec when available", func() {
- mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
- Expect(mf.AudioCodec()).To(Equal("aac"))
- })
-
- It("returns stored codec lowercased", func() {
- mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
- Expect(mf.AudioCodec()).To(Equal("alac"))
- })
-
- DescribeTable("infers codec from suffix when Codec field is empty",
- func(suffix string, bitDepth int, expected string) {
- mf := MediaFile{Suffix: suffix, BitDepth: new(bitDepth)}
- Expect(mf.AudioCodec()).To(Equal(expected))
- },
- Entry("mp3", "mp3", 0, "mp3"),
- Entry("mpga", "mpga", 0, "mp3"),
- Entry("mp2", "mp2", 0, "mp2"),
- Entry("ogg", "ogg", 0, "vorbis"),
- Entry("oga", "oga", 0, "vorbis"),
- Entry("opus", "opus", 0, "opus"),
- Entry("mpc", "mpc", 0, "mpc"),
- Entry("wma", "wma", 0, "wma"),
- Entry("flac", "flac", 0, "flac"),
- Entry("wav", "wav", 0, "pcm"),
- Entry("aif", "aif", 0, "pcm"),
- Entry("aiff", "aiff", 0, "pcm"),
- Entry("aifc", "aifc", 0, "pcm"),
- Entry("ape", "ape", 0, "ape"),
- Entry("wv", "wv", 0, "wv"),
- Entry("wvp", "wvp", 0, "wv"),
- Entry("tta", "tta", 0, "tta"),
- Entry("tak", "tak", 0, "tak"),
- Entry("shn", "shn", 0, "shn"),
- Entry("dsf", "dsf", 0, "dsd"),
- Entry("dff", "dff", 0, "dsd"),
- Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
- Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
- Entry("m4b", "m4b", 0, "aac"),
- Entry("m4p", "m4p", 0, "aac"),
- Entry("m4r", "m4r", 0, "aac"),
- Entry("unknown suffix", "xyz", 0, ""),
- )
-
- It("prefers stored codec over suffix inference", func() {
- mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
- 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 {
diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go
index 7156c4f50..0a3bf0bf3 100644
--- a/model/metadata/legacy_ids.go
+++ b/model/metadata/legacy_ids.go
@@ -9,23 +9,21 @@ 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 {
- _, _, releaseDate := md.mapDates()
+ releaseDate := legacyReleaseDate(md)
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md)))
if !conf.Server.Scanner.GroupAlbumReleases {
if len(releaseDate) != 0 {
@@ -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 {
@@ -58,3 +55,9 @@ func legacyMapAlbumName(md Metadata) string {
consts.UnknownAlbum,
)
}
+
+// Keep the TaggedLikePicard logic for backwards compatibility
+func legacyReleaseDate(md Metadata) string {
+ _, _, releaseDate := md.mapDates()
+ return string(releaseDate)
+}
diff --git a/model/metadata/legacy_ids_test.go b/model/metadata/legacy_ids_test.go
new file mode 100644
index 000000000..b6d096763
--- /dev/null
+++ b/model/metadata/legacy_ids_test.go
@@ -0,0 +1,30 @@
+package metadata
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("legacyReleaseDate", func() {
+
+ DescribeTable("legacyReleaseDate",
+ func(recordingDate, originalDate, releaseDate, expected string) {
+ md := New("", Info{
+ Tags: map[string][]string{
+ "DATE": {recordingDate},
+ "ORIGINALDATE": {originalDate},
+ "RELEASEDATE": {releaseDate},
+ },
+ })
+
+ result := legacyReleaseDate(md)
+ Expect(result).To(Equal(expected))
+ },
+ Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
+ Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
+ )
+})
diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go
index 2135824d2..c64e8c724 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,11 +63,8 @@ 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()
mf.Suffix = md.Suffix()
mf.Size = md.Size()
@@ -83,9 +76,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 +132,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..ddda39bc2 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{
@@ -88,23 +75,6 @@ var _ = Describe("ToMediaFile", func() {
Expect(mf.OriginalYear).To(Equal(1966))
Expect(mf.ReleaseYear).To(Equal(2014))
})
- DescribeTable("legacyReleaseDate (TaggedLikePicard old behavior)",
- func(recordingDate, originalDate, releaseDate, expected string) {
- mf := toMediaFile(model.RawTags{
- "DATE": {recordingDate},
- "ORIGINALDATE": {originalDate},
- "RELEASEDATE": {releaseDate},
- })
-
- Expect(mf.ReleaseDate).To(Equal(expected))
- },
- Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
- Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
- )
})
Describe("Lyrics", func() {
@@ -121,8 +91,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 +101,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..1372d0034 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"
@@ -36,7 +35,6 @@ type AudioProperties struct {
BitDepth int
SampleRate int
Channels int
- Codec string
}
type Date string
@@ -148,20 +146,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 +204,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:
@@ -258,15 +250,7 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model
id3Base := parseID3Pairs(name, lowered)
if len(aliasValues) > 0 {
- // For lyrics, don't use parseVorbisPairs as parentheses in lyrics content
- // should not be interpreted as language keys (e.g. "(intro)" is not a language)
- if name == model.TagLyrics {
- for _, v := range aliasValues {
- id3Base = append(id3Base, NewPair("xxx", v))
- }
- } else {
- id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
- }
+ id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
}
return id3Base
}
@@ -276,8 +260,8 @@ func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
prefix := string(name) + ":"
for tagKey, tagValues := range lowered {
keyStr := string(tagKey)
- if after, ok := strings.CutPrefix(keyStr, prefix); ok {
- keyPart := after
+ if strings.HasPrefix(keyStr, prefix) {
+ keyPart := strings.TrimPrefix(keyStr, prefix)
if keyPart == string(name) {
keyPart = ""
}
@@ -372,14 +356,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 +377,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..82afd8657 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() {
@@ -303,18 +246,6 @@ var _ = Describe("Metadata", func() {
metadata.NewPair("eng", "Lyrics"),
))
})
-
- It("should preserve lyrics starting with parentheses from alias tags", func() {
- props.Tags = model.RawTags{
- "LYRICS": {"(line one)\nline two\nline three"},
- }
- md = metadata.New(filePath, props)
-
- Expect(md.All()).To(HaveKey(model.TagLyrics))
- Expect(md.Strings(model.TagLyrics)).To(ContainElements(
- metadata.NewPair("xxx", "(line one)\nline two\nline three"),
- ))
- })
})
Describe("ReplayGain", func() {
@@ -323,7 +254,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 +262,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 +273,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 +287,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..b45882946 100644
--- a/model/metadata/persistent_ids.go
+++ b/model/metadata/persistent_ids.go
@@ -2,128 +2,93 @@ 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"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils"
+ "github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type hashFunc = func(...string) string
-// computePID calculates the persistent ID for a given spec. The spec is a
-// pipe-separated list of fields, where each field is a comma-separated list of
-// 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.
-//
-// 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 {
- switch spec {
- case "track_legacy":
- return legacyTrackID(mf, prependLibId)
- case "album_legacy":
- return legacyAlbumID(mf, md, prependLibId)
- }
- pid := ""
- fields := strings.SplitSeq(spec, "|")
- for field := range fields {
- attributes := strings.Split(field, ",")
- values := make([]string, len(attributes))
- hasValue := false
- for i, attr := range attributes {
- v := getPIDAttr(mf, md, attr, prependLibId, spec, albumSpec, hash)
- if v != "" {
- hasValue = true
- }
- values[i] = v
- }
- if hasValue {
- pid += strings.Join(values, "\\")
- break
- }
- }
- if prependLibId {
- pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
- }
- return hash(pid)
-}
+// createGetPID returns a function that calculates the persistent ID for a given spec, getting the referenced values from the metadata
+// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes
+// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc.
+// For each field, it gets all its attributes values and concatenates them, then hashes the result.
+// If a field is empty, it is skipped and the function looks for the next field.
+type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string
-func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string {
- attr = strings.TrimSpace(strings.ToLower(attr))
- switch attr {
- case "albumid":
- if spec == albumSpec {
- log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
- return ""
+func createGetPID(hash hashFunc) getPIDFunc {
+ var getPID getPIDFunc
+ getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool) string {
+ attr = strings.TrimSpace(strings.ToLower(attr))
+ switch attr {
+ case "albumid":
+ return getPID(mf, md, conf.Server.PID.Album, prependLibId)
+ case "folder":
+ return filepath.Dir(mf.Path)
+ case "albumartistid":
+ return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
+ case "title":
+ return mf.Title
+ case "album":
+ return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
}
- return computePID(mf, md, albumSpec, albumSpec, prependLibId, hash)
- case "folder":
- return filepath.Dir(mf.Path)
- case "albumartistid":
- return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
- case "title":
- return mf.Title
- case "album":
- return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
+ return md.String(model.TagName(attr))
}
- 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)
+ getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
+ pid := ""
+ fields := strings.Split(spec, "|")
+ for _, field := range fields {
+ attributes := strings.Split(field, ",")
+ hasValue := false
+ values := slice.Map(attributes, func(attr string) string {
+ v := getAttr(mf, md, attr, prependLibId)
+ if v != "" {
+ hasValue = true
}
+ return v
+ })
+ if hasValue {
+ pid += strings.Join(values, "\\")
+ break
}
}
+ if prependLibId {
+ pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
+ }
+ return hash(pid)
+ }
+
+ return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
+ switch spec {
+ case "track_legacy":
+ return legacyTrackID(mf, prependLibId)
+ case "album_legacy":
+ return legacyAlbumID(mf, md, prependLibId)
+ }
+ return getPID(mf, md, spec, prependLibId)
}
- 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 createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true)
}
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
- return computePID(mf, md, pidConf, pidConf, true, id.NewHash)
+ return createGetPID(id.NewHash)(mf, md, pidConf, true)
}
// 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 createGetPID(id.NewHash)(mf, md, "albumartistid", false)
}
func (md Metadata) mapTrackTitle() string {
diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go
index 9f6eaf1f4..7ae0c91f7 100644
--- a/model/metadata/persistent_ids_test.go
+++ b/model/metadata/persistent_ids_test.go
@@ -3,27 +3,24 @@ 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"
. "github.com/onsi/gomega"
)
var _ = Describe("getPID", func() {
var (
- md Metadata
- mf model.MediaFile
- sum hashFunc
- albumSpec string
+ md Metadata
+ mf model.MediaFile
+ sum hashFunc
+ getPID getPIDFunc
)
- getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
- return computePID(mf, md, spec, albumSpec, prependLibId, sum)
- }
BeforeEach(func() {
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
- albumSpec = consts.DefaultAlbumPID
+ getPID = createGetPID(sum)
})
Context("attributes are tags", func() {
@@ -67,7 +64,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,version,releasedate"
})
When("field is title", func() {
It("should return the pid", func() {
@@ -80,7 +78,6 @@ var _ = Describe("getPID", func() {
})
When("field is folder", func() {
It("should return the pid", func() {
- tests.SkipOnWindows("path separator bug (#TBD-path-sep-metadata)")
spec := "folder|title"
md.tags = map[model.TagName][]string{"title": {"title"}}
mf.Path = "/path/to/file.mp3"
@@ -91,13 +88,13 @@ var _ = Describe("getPID", func() {
It("should return the pid", func() {
spec := "albumid|title"
md.tags = map[model.TagName][]string{
- "title": {"title"},
- "album": {"album name"},
- "albumversion": {"deluxe edition"},
- "releasedate": {"2021-01-01"},
+ "title": {"title"},
+ "album": {"album name"},
+ "version": {"version"},
+ "releasedate": {"2021-01-01"},
}
mf.AlbumArtist = "Album Artist"
- Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\deluxe edition\\2021-01-01))"))
+ Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))"))
})
})
When("field is albumartistid", func() {
@@ -117,24 +114,6 @@ var _ = Describe("getPID", func() {
Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
})
})
-
- 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
- md.tags = map[model.TagName][]string{
- "album": {"Album Name"},
- "albumversion": {"Version"},
- "releasedate": {"2022"},
- }
- // Should not panic and return a valid PID ignoring the recursive "albumid"
- Expect(func() {
- pid := getPID(mf, md, spec, false)
- Expect(pid).To(Equal("(\\album name\\Version\\2022)"))
- }).To(Not(Panic()))
- })
- })
})
Context("edge cases", func() {
@@ -205,7 +184,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 +197,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 +270,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..a87019ed5 100644
--- a/model/playlist.go
+++ b/model/playlist.go
@@ -1,43 +1,28 @@
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"`
- Duration float32 `structs:"duration" json:"duration"`
- Size int64 `structs:"size" json:"size"`
- SongCount int `structs:"song_count" json:"songCount"`
- OwnerName string `structs:"-" json:"ownerName"`
- OwnerID string `structs:"owner_id" json:"ownerId"`
- Public bool `structs:"public" json:"public"`
- Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
- Path string `structs:"path" json:"path"`
- Sync bool `structs:"sync" json:"sync"`
- UploadedImage string `structs:"uploaded_image" json:"uploadedImage"`
- 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:"-"`
+ ID string `structs:"id" json:"id"`
+ Name string `structs:"name" json:"name"`
+ Comment string `structs:"comment" json:"comment"`
+ Duration float32 `structs:"duration" json:"duration"`
+ Size int64 `structs:"size" json:"size"`
+ SongCount int `structs:"song_count" json:"songCount"`
+ OwnerName string `structs:"-" json:"ownerName"`
+ OwnerID string `structs:"owner_id" json:"ownerId"`
+ Public bool `structs:"public" json:"public"`
+ Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
+ Path string `structs:"path" json:"path"`
+ Sync bool `structs:"sync" json:"sync"`
+ CreatedAt time.Time `structs:"created_at" json:"createdAt"`
+ UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// SmartPlaylist attributes
Rules *criteria.Criteria `structs:"rules" json:"rules"`
@@ -48,20 +33,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
@@ -135,92 +106,20 @@ func (pls Playlist) CoverArtID() ArtworkID {
return artworkIDFromPlaylist(pls)
}
-// UploadedImagePath returns the absolute filesystem path for a manually uploaded
-// playlist cover image. Returns empty string if no image has been uploaded.
-// This does NOT cover sidecar images or external URLs — those are resolved
-// by the artwork reader's fallback chain.
-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) 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 +139,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..a54cecd53 100644
--- a/model/playlist_test.go
+++ b/model/playlist_test.go
@@ -1,14 +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"
)
@@ -34,7 +27,6 @@ var _ = Describe("Playlist", func() {
}
})
It("generates the correct M3U format", func() {
- tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")
expected := `#EXTM3U
#PLAYLIST:Mellow sunset
#EXTINF:378,Morcheeba feat. Kurt Wagner - What New York Couples Fight About
@@ -49,142 +41,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
deleted file mode 100644
index a448a9633..000000000
--- a/model/plugin.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package model
-
-import (
- "context"
- "time"
-
- "github.com/deluan/rest"
-)
-
-type Plugin struct {
- ID string `structs:"id" json:"id"`
- Path string `structs:"path" json:"path"`
- Manifest string `structs:"manifest" json:"manifest"`
- Config string `structs:"config" json:"config,omitempty"`
- Users string `structs:"users" json:"users,omitempty"`
- AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
- Libraries string `structs:"libraries" json:"libraries,omitempty"`
- AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
- AllowWriteAccess bool `structs:"allow_write_access" json:"allowWriteAccess,omitempty"`
- Enabled bool `structs:"enabled" json:"enabled"`
- LastError string `structs:"last_error" json:"lastError,omitempty"`
- SHA256 string `structs:"sha256" json:"sha256"`
- CreatedAt time.Time `structs:"created_at" json:"createdAt"`
- UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
-}
-
-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
-}
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..567d32e44 100644
--- a/model/radio.go
+++ b/model/radio.go
@@ -1,41 +1,23 @@
package model
-import (
- "context"
- "time"
-
- "github.com/deluan/rest"
- "github.com/navidrome/navidrome/consts"
-)
+import "time"
type Radio struct {
- ItemImage `structs:"-"`
-
- ID string `structs:"id" json:"id"`
- StreamUrl string `structs:"stream_url" json:"streamUrl"`
- Name string `structs:"name" json:"name"`
- HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
- UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
- CreatedAt time.Time `structs:"created_at" json:"createdAt"`
- UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
-}
-
-func (r Radio) CoverArtID() ArtworkID {
- return artworkIDFromRadio(r)
-}
-
-func (r Radio) UploadedImagePath() string {
- return UploadedImagePath(consts.EntityRadio, r.UploadedImage)
+ ID string `structs:"id" json:"id"`
+ StreamUrl string `structs:"stream_url" json:"streamUrl"`
+ Name string `structs:"name" json:"name"`
+ HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
+ CreatedAt time.Time `structs:"created_at" json:"createdAt"`
+ UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
}
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) error
}
diff --git a/model/radio_test.go b/model/radio_test.go
deleted file mode 100644
index 6ae74c3f4..000000000
--- a/model/radio_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-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/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("Radio", func() {
- Describe("CoverArtID", func() {
- It("returns a radio artwork ID", func() {
- r := model.Radio{ID: "rd-1", UpdatedAt: time.Now()}
- artID := r.CoverArtID()
- Expect(artID.Kind).To(Equal(model.KindRadioArtwork))
- Expect(artID.ID).To(Equal("rd-1"))
- Expect(artID.LastUpdate.IsZero()).To(BeTrue())
- })
- })
-
- Describe("UploadedImagePath", func() {
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.DataFolder = conf.NewDir("/data")
- })
-
- It("returns empty string when no image uploaded", func() {
- r := model.Radio{ID: "rd-1"}
- Expect(r.UploadedImagePath()).To(BeEmpty())
- })
-
- It("returns full path when image is set", func() {
- r := model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"}
- Expect(r.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "radio", "rd-1_test.jpg")))
- })
- })
-})
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
deleted file mode 100644
index d22c3d0d6..000000000
--- a/model/scanner.go
+++ /dev/null
@@ -1,84 +0,0 @@
-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
-}
-
-func (st ScanTarget) String() string {
- return fmt.Sprintf("%d:%s", st.LibraryID, st.FolderPath)
-}
-
-// ScannerStatus holds information about the current scan status
-type ScannerStatus struct {
- Scanning bool
- LastScan time.Time
- Count uint32
- FolderCount uint32
- LastError string
- ScanType string
- ElapsedTime time.Duration
-}
-
-type Scanner interface {
- // ScanAll starts a scan of all libraries. This is a blocking operation.
- ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
- // ScanFolders scans specific library/folder pairs, recursing into subdirectories.
- // If targets is nil, it scans all libraries. This is a blocking operation.
- ScanFolders(ctx context.Context, fullScan bool, targets []ScanTarget) (warnings []string, err error)
- Status(context.Context) (*ScannerStatus, error)
-}
-
-// ParseTargets parses scan targets strings into ScanTarget structs.
-// Example: []string{"1:Music/Rock", "2:Classical"}
-func ParseTargets(libFolders []string) ([]ScanTarget, error) {
- targets := make([]ScanTarget, 0, len(libFolders))
-
- for _, part := range libFolders {
- part = strings.TrimSpace(part)
- if part == "" {
- continue
- }
-
- // Split by the first colon
- before, after, ok := strings.Cut(part, ":")
- if !ok {
- return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part)
- }
-
- libIDStr := before
- folderPath := after
-
- libID, err := strconv.Atoi(libIDStr)
- if err != nil {
- return nil, fmt.Errorf("invalid library ID %q: %w", libIDStr, err)
- }
- if libID <= 0 {
- return nil, fmt.Errorf("invalid library ID %q", libIDStr)
- }
-
- targets = append(targets, ScanTarget{
- LibraryID: libID,
- FolderPath: folderPath,
- })
- }
-
- if len(targets) == 0 {
- return nil, fmt.Errorf("no valid targets found")
- }
-
- return targets, nil
-}
diff --git a/model/scanner_test.go b/model/scanner_test.go
deleted file mode 100644
index 8ca0c53fa..000000000
--- a/model/scanner_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package model_test
-
-import (
- "github.com/navidrome/navidrome/model"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("ParseTargets", func() {
- It("parses multiple entries in slice", func() {
- targets, err := model.ParseTargets([]string{"1:Music/Rock", "1:Music/Jazz", "2:Classical"})
- Expect(err).ToNot(HaveOccurred())
- Expect(targets).To(HaveLen(3))
- Expect(targets[0].LibraryID).To(Equal(1))
- Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
- Expect(targets[1].LibraryID).To(Equal(1))
- Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
- Expect(targets[2].LibraryID).To(Equal(2))
- Expect(targets[2].FolderPath).To(Equal("Classical"))
- })
-
- It("handles empty folder paths", func() {
- targets, err := model.ParseTargets([]string{"1:", "2:"})
- Expect(err).ToNot(HaveOccurred())
- Expect(targets).To(HaveLen(2))
- Expect(targets[0].FolderPath).To(Equal(""))
- Expect(targets[1].FolderPath).To(Equal(""))
- })
-
- It("trims whitespace from entries", func() {
- targets, err := model.ParseTargets([]string{" 1:Music/Rock", " 2:Classical "})
- Expect(err).ToNot(HaveOccurred())
- Expect(targets).To(HaveLen(2))
- Expect(targets[0].LibraryID).To(Equal(1))
- Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
- Expect(targets[1].LibraryID).To(Equal(2))
- Expect(targets[1].FolderPath).To(Equal("Classical"))
- })
-
- It("skips empty strings", func() {
- targets, err := model.ParseTargets([]string{"1:Music/Rock", "", "2:Classical"})
- Expect(err).ToNot(HaveOccurred())
- Expect(targets).To(HaveLen(2))
- })
-
- It("handles paths with colons", func() {
- targets, err := model.ParseTargets([]string{"1:C:/Music/Rock", "2:/path:with:colons"})
- Expect(err).ToNot(HaveOccurred())
- Expect(targets).To(HaveLen(2))
- Expect(targets[0].FolderPath).To(Equal("C:/Music/Rock"))
- Expect(targets[1].FolderPath).To(Equal("/path:with:colons"))
- })
-
- It("returns error for invalid format without colon", func() {
- _, err := model.ParseTargets([]string{"1Music/Rock"})
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("invalid target format"))
- })
-
- It("returns error for non-numeric library ID", func() {
- _, err := model.ParseTargets([]string{"abc:Music/Rock"})
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("invalid library ID"))
- })
-
- It("returns error for negative library ID", func() {
- _, err := model.ParseTargets([]string{"-1:Music/Rock"})
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("invalid library ID"))
- })
-
- It("returns error for zero library ID", func() {
- _, err := model.ParseTargets([]string{"0:Music/Rock"})
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("invalid library ID"))
- })
-
- It("returns error for empty input", func() {
- _, err := model.ParseTargets([]string{})
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("no valid targets found"))
- })
-
- It("returns error for all empty strings", func() {
- _, err := model.ParseTargets([]string{"", " ", ""})
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("no valid targets found"))
- })
-})
diff --git a/model/scrobble.go b/model/scrobble.go
deleted file mode 100644
index b99d25b6a..000000000
--- a/model/scrobble.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package model
-
-import (
- "context"
- "time"
-
- "github.com/deluan/rest"
-)
-
-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"`
-}
-
-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
-}
-
-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..631a11726 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, offset, size int, options ...QueryOptions) (T, error)
}
diff --git a/model/share.go b/model/share.go
index 952fd9759..acb5fb428 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"
)
@@ -24,8 +22,8 @@ type Share struct {
Format string `structs:"format" json:"format,omitempty"`
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate,omitempty"`
VisitCount int `structs:"visit_count" json:"visitCount,omitempty"`
- CreatedAt time.Time `structs:"created_at" json:"createdAt"`
- UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
+ CreatedAt time.Time `structs:"created_at" json:"createdAt,omitempty"`
+ UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
Tracks MediaFiles `structs:"-" json:"tracks,omitempty"`
Albums Albums `structs:"-" json:"albums,omitempty"`
URL string `structs:"-" json:"-"`
@@ -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..674f688ca 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)
}
@@ -157,17 +144,17 @@ func (t Tags) Merge(tags Tags) {
}
func (t Tags) Add(name TagName, v string) {
- if slices.Contains(t[name], v) {
- return
+ for _, existing := range t[name] {
+ if existing == v {
+ return
+ }
}
t[name] = append(t[name], v)
}
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 +194,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 +242,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..aabedc096 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,19 +14,15 @@ 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
+ NewPassword string `structs:"password,omitempty" json:"password,omitempty"`
// If changing the password, this is also required
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
}
@@ -49,21 +42,18 @@ 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)
+ CountAll(...QueryOptions) (int64, error)
+ Get(id string) (*User, 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..6f9bb3b48 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -3,9 +3,7 @@ package persistence
import (
"context"
"encoding/json"
- "errors"
"fmt"
- "iter"
"maps"
"slices"
"strings"
@@ -14,11 +12,11 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"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,21 +57,16 @@ func (a *dbAlbum) PostScan() error {
}
a.Album.FolderIDs = ids
}
- a.Album.RGAlbumGain = a.RgAlbumGain
- a.Album.RGAlbumPeak = a.RgAlbumPeak
return nil
}
func (a *dbAlbum) PostMapArgs(args map[string]any) error {
fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist}
- participantNames := a.Album.Participants.AllNames()
- fullText = append(fullText, participantNames...)
+ fullText = append(fullText, a.Album.Participants.AllNames()...)
fullText = append(fullText, slices.Collect(maps.Values(a.Album.Discs))...)
fullText = append(fullText, a.Album.Tags[model.TagAlbumVersion]...)
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["tags"] = marshalTags(a.Album.Tags)
args["participants"] = marshalParticipants(a.Album.Participants)
@@ -102,8 +91,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,11 +102,10 @@ 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",
- "rated_at": "rating, rated_at",
})
return r
}
@@ -129,11 +118,10 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
"artist_id": artistFilter,
"year": yearFilter,
"recently_played": recentlyPlayedFilter,
- "starred": annotationBoolFilter("starred"),
- "has_rating": annotationBoolFilter("rating"),
- "played": annotationBoolFilter("play_count"),
+ "starred": booleanFilter,
+ "has_rating": hasRatingFilter,
"missing": booleanFilter,
- "genre_id": genreFilter(AlbumGenres),
+ "genre_id": tagIDFilter,
"role_total_id": allRolesFilter,
"library_id": libraryIdFilter,
}
@@ -151,16 +139,20 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
func recentlyAddedSort() string {
if conf.Server.RecentlyAddedByModTime {
- return "album.updated_at, album.id"
+ return "updated_at"
}
- return "album.created_at, album.id"
+ return "created_at"
}
-func recentlyPlayedFilter(string, any) Sqlizer {
+func recentlyPlayedFilter(string, interface{}) Sqlizer {
return Gt{"play_count": 0}
}
-func yearFilter(_ string, value any) Sqlizer {
+func hasRatingFilter(string, interface{}) Sqlizer {
+ return Gt{"rating": 0}
+}
+
+func yearFilter(_ string, value interface{}) Sqlizer {
return Or{
And{
Gt{"min_year": 0},
@@ -171,68 +163,69 @@ func yearFilter(_ string, value any) Sqlizer {
}
}
-func artistFilter(_ string, value any) Sqlizer {
- return ParticipantIDFilter("album", value, model.RoleAlbumArtist, model.RoleArtist)
+func artistFilter(_ string, value interface{}) Sqlizer {
+ 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 {
+func artistRoleFilter(name string, value interface{}) 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)
+func allRolesFilter(_ string, value interface{}) Sqlizer {
+ 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 {
+ err = r.updateParticipants(al.ID, al.Participants)
+ if err != nil {
+ return err
+ }
}
- return r.updateTags(ctx, al.ID, al.Tags)
-}
-
-// 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")
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").
- LeftJoin("library on album.library_id = library.id")
- sql = r.withAnnotation(ctx, sql, "album.id")
- return r.applyLibraryFilter(ctx, sql)
+// TODO Move external metadata to a separated table
+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) Get(ctx context.Context, id string) (*model.Album, error) {
- res, err := r.GetAll(ctx, model.QueryOptions{Filters: Eq{"album.id": id}})
+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(sql, "album.id")
+ return r.applyLibraryFilter(sql)
+}
+
+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,144 +235,55 @@ 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(), err
}
-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)
}
- to := make(map[string]any)
+ to := make(map[string]interface{})
for _, col := range columns {
- v := from[col]
- // created_at is aggregated from song birth_times and must never be
- // overwritten with a zero/poisoned value, or it propagates forward on
- // every metadata-driven album ID change.
- if col == "created_at" && (!v.Valid || v.String == "" || strings.HasPrefix(v.String, "0001-")) {
- continue
- }
- to[col] = v
+ to[col] = from[col]
}
- 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,26 +293,32 @@ 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
}
- return wrapAlbumCursor(cursor), nil
-}
-
-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", a))
+ return
+ }
+ if !yield(*a.Album, err) || err != nil {
+ return
+ }
+ }
+ }, nil
}
// 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,57 +334,56 @@ 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() 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
}
-var albumSearchConfig = searchConfig{
- NaturalOrder: "album.rowid",
- OrderBy: []string{"name"},
- MBIDFields: []string{"mbz_album_id", "mbz_release_group_id"},
-}
-
-func (r *albumRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Albums, error) {
- var opts model.QueryOptions
- if len(options) > 0 {
- opts = options[0]
- }
+func (r *albumRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) {
var res dbAlbums
- err := r.doSearch(ctx, r.selectAlbum(ctx, options...), q, &res, albumSearchConfig, opts)
- if err != nil {
- return nil, fmt.Errorf("searching album %q: %w", q, err)
+ if uuid.Validate(q) == nil {
+ err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, &res)
+ if err != nil {
+ return nil, fmt.Errorf("searching album by MBID %q: %w", q, err)
+ }
+ } else {
+ err := r.doSearch(r.selectAlbum(options...), q, offset, size, &res, "album.rowid", "name")
+ if err != nil {
+ return nil, fmt.Errorf("searching album by query %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) (interface{}, 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) (interface{}, error) {
+ return r.GetAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *albumRepository) EntityName() string {
+ return "album"
+}
+
+func (r *albumRepository) NewInstance() interface{} {
+ 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..a062b4398 100644
--- a/persistence/album_repository_test.go
+++ b/persistence/album_repository_test.go
@@ -1,86 +1,30 @@
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{}
}
@@ -95,149 +39,9 @@ var _ = Describe("AlbumRepository", func() {
})
})
- Describe("CopyAttributes", func() {
- var srcTime, dstTime time.Time
- 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())
- DeferCleanup(func() {
- _, _ = albumRepo.executeSQL(ctx, 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(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(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{}
}
@@ -251,23 +55,17 @@ var _ = Describe("AlbumRepository", func() {
It("returns all records sorted", func() {
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
albumAbbeyRoad,
- albumWithVersion,
- albumCJK,
albumMultiDisc,
albumRadioactivity,
albumSgtPeppers,
- albumPunctuation,
}))
})
It("returns all records sorted desc", func() {
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
- albumPunctuation,
albumSgtPeppers,
albumRadioactivity,
albumMultiDisc,
- albumCJK,
- albumWithVersion,
albumAbbeyRoad,
}))
})
@@ -279,220 +77,6 @@ var _ = Describe("AlbumRepository", func() {
})
})
- Describe("recently_added sort", func() {
- AfterEach(func() {
- _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").
- Where(squirrel.Like{"id": "ra-%"}))
- })
-
- // Sub-second precision must survive, and ties must break deterministically
- // so the order is independent of any filter (issue #5673).
- indexOf := func(albums model.Albums, id string) int {
- for i, a := range albums {
- if a.ID == id {
- return i
- }
- }
- return -1
- }
-
- It("orders by sub-second precision, not truncated to the second", func() {
- // Same second, different nanoseconds: datetime() would tie these.
- earlier := &model.Album{LibraryID: 1, ID: "ra-earlier", Name: "Earlier"}
- later := &model.Album{LibraryID: 1, ID: "ra-later", Name: "Later"}
- Expect(albumRepo.Put(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")
- })
- })
-
- Context("Filters", func() {
- var albumWithoutAnnotation model.Album
-
- 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())
- })
-
- AfterEach(func() {
- _, _ = albumRepo.executeSQL(ctx, 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{
- Filters: map[string]any{"starred": "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 starred=false filter")
- })
-
- It("true excludes items without annotations", func() {
- res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
- Filters: map[string]any{"starred": "true"},
- })
- Expect(err).ToNot(HaveOccurred())
- albums := res
-
- for _, a := range albums {
- Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
- }
- })
- })
-
- Describe("has_rating", func() {
- It("false includes items without annotations", func() {
- res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
- Filters: map[string]any{"has_rating": "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 has_rating=false filter")
- })
-
- It("true excludes items without annotations", func() {
- res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
- Filters: map[string]any{"has_rating": "true"},
- })
- Expect(err).ToNot(HaveOccurred())
- albums := res
-
- 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() {
// Implementation is in withAnnotation() method
DescribeTable("normalizes play count when AlbumPlayCountMode is absolute",
@@ -500,12 +84,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())
- for range playCount {
- Expect(albumRepo.IncPlayCount(ctx, newID, time.Now())).To(Succeed())
+ Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
+ for i := 0; i < playCount; i++ {
+ 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 +107,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())
- for range playCount {
- Expect(albumRepo.IncPlayCount(ctx, newID, time.Now())).To(Succeed())
+ Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
+ for i := 0; i < playCount; i++ {
+ 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)))
},
@@ -542,85 +126,6 @@ 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())
-
- album, err := albumRepo.Get(ctx, newID)
- Expect(err).ToNot(HaveOccurred())
- Expect(album.AverageRating).To(Equal(0.0))
-
- _, _ = albumRepo.executeSQL(ctx, 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())
-
- album, err := albumRepo.Get(ctx, 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}))
- })
-
- 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.SetRating(ctx, 4, newID)).To(Succeed())
-
- user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
- Expect(albumRepo.SetRating(user2Ctx, 5, newID)).To(Succeed())
-
- album, err := albumRepo.Get(ctx, 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}))
- })
-
- 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())
-
- user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
- Expect(albumRepo.SetRating(user2Ctx, 0, newID)).To(Succeed())
-
- album, err := albumRepo.Get(ctx, 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}))
- })
-
- 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.SetRating(ctx, 5, newID)).To(Succeed())
-
- user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
- Expect(albumRepo.SetRating(user2Ctx, 4, newID)).To(Succeed())
-
- user3Ctx := request.WithUser(GinkgoT().Context(), thirdUser)
- Expect(albumRepo.SetRating(user3Ctx, 4, newID)).To(Succeed())
-
- album, err := albumRepo.Get(ctx, 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}))
- })
- })
-
Describe("dbAlbum mapping", func() {
var (
a model.Album
@@ -736,16 +241,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([]interface{}{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 +267,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([]interface{}{"test-id"}))
}
})
@@ -786,11 +294,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 +309,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 +322,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 +343,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 +354,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 +370,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 +396,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 +410,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 +424,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 +451,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 +470,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 +502,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,243 +510,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}))
- })
-
- It("removes stale role associations when artist role changes", func() {
- // Regression test for issue #4242: Composers displayed in albumartist list
- // This happens when an artist's role changes (e.g., was both albumartist and composer,
- // now only composer) and the old role association isn't properly removed.
-
- // Create an artist that will have changing roles
- artist := &model.Artist{
- ID: "role-change-artist-1",
- Name: "Role Change Artist",
- OrderArtistName: "role change artist",
- }
- err := createArtistWithLibrary(artistCtx, artistRepo, artist, 1)
- Expect(err).ToNot(HaveOccurred())
-
- // Create album with artist as both albumartist and composer
- album := &model.Album{
- LibraryID: 1,
- ID: "test-album-role-change",
- Name: "Test Album Role Change",
- AlbumArtistID: "role-change-artist-1",
- AlbumArtist: "Role Change Artist",
- Participants: model.Participants{
- model.RoleAlbumArtist: {
- {Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
- },
- model.RoleComposer: {
- {Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
- },
- },
- }
-
- err = albumRepo.Put(ctx, album)
- Expect(err).ToNot(HaveOccurred())
-
- // Verify initial state: artist has both albumartist and composer roles
- expected := []albumArtistRecord{
- {ArtistID: "role-change-artist-1", Role: "albumartist", SubRole: ""},
- {ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
- }
- verifyAlbumArtists(album.ID, expected)
-
- // Now update album so artist is ONLY a composer (remove albumartist role)
- album.Participants = model.Participants{
- model.RoleComposer: {
- {Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
- },
- }
-
- err = albumRepo.Put(ctx, album)
- Expect(err).ToNot(HaveOccurred())
-
- // Verify that the albumartist role was removed - only composer should remain
- // This is the key test: before the fix, the albumartist role would remain
- // causing composers to appear in the albumartist filter
- expectedAfter := []albumArtistRecord{
- {ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
- }
- 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))
- })
- })
-
- Describe("wrapAlbumCursor", func() {
- It("does not panic when the cursor yields a dbAlbum with nil Album", func() {
- // Simulate what queryWithStableResults does on the rows.Err() path:
- // it yields a zero-value dbAlbum (where Album is nil) with an error.
- dbErr := fmt.Errorf("database is locked")
- cursor := func(yield func(dbAlbum, error) bool) {
- var empty dbAlbum // Album pointer is nil
- yield(empty, dbErr)
- }
-
- // wrapAlbumCursor should handle the nil Album without panicking
- wrappedCursor := wrapAlbumCursor(cursor)
- var gotErr error
- Expect(func() {
- for _, err := range wrappedCursor {
- gotErr = err
- }
- }).ToNot(Panic())
- Expect(gotErr).To(HaveOccurred())
- Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Album"))
- Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
- })
-
- It("yields albums from a valid cursor", func() {
- album := &model.Album{ID: "a1", Name: "Test"}
- cursor := func(yield func(dbAlbum, error) bool) {
- yield(dbAlbum{Album: album}, nil)
- }
-
- wrappedCursor := wrapAlbumCursor(cursor)
- var albums []model.Album
- for a, err := range wrappedCursor {
- Expect(err).ToNot(HaveOccurred())
- albums = append(albums, a)
- }
- Expect(albums).To(HaveLen(1))
- 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()
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
})
})
})
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index b90a48c48..6d08c27db 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -4,23 +4,20 @@ import (
"cmp"
"context"
"encoding/json"
- "errors"
"fmt"
- "maps"
- "os"
"slices"
"strings"
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "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/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 +101,7 @@ 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)
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
// TODO: Better way to handle this?
@@ -130,24 +124,23 @@ 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
r.registerModel(&model.Artist{}, map[string]filterFunc{
"id": idFilter(r.tableName),
"name": fullTextFilter(r.tableName, "mbz_artist_id"),
- "starred": annotationBoolFilter("starred"),
- "has_rating": annotationBoolFilter("rating"),
+ "starred": booleanFilter,
"role": roleFilter,
"missing": booleanFilter,
"library_id": artistLibraryIdFilter,
})
- r.setSortMappings(map[string]string{ //nolint:gosec
+ r.setSortMappings(map[string]string{
"name": "order_artist_name",
"starred_at": "starred, starred_at",
- "rated_at": "rating, rated_at",
"song_count": "stats->>'total'->>'m'",
"album_count": "stats->>'total'->>'a'",
"size": "stats->>'total'->>'s'",
@@ -162,34 +155,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 {
+func artistLibraryIdFilter(_ string, value interface{}) 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 +184,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 +269,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 +297,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,58 +312,20 @@ func (r *artistRepository) GetIndex(ctx context.Context, includeMissing bool, li
return result, nil
}
-func (r *artistRepository) purgeEmpty(ctx context.Context) error {
- orphanFilter := "id not in (select artist_id from album_artists)"
-
- // Collect uploaded image filenames before deleting
- sel := Select("uploaded_image").From(r.tableName).
- Where(orphanFilter).
- Where("uploaded_image != ''")
- var imageFiles []string
- if err := r.queryAllSlice(ctx, 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)
+func (r *artistRepository) purgeEmpty() error {
+ del := Delete(r.tableName).Where("id not in (select artist_id from album_artists)")
+ 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)
- }
-
- if len(imageFiles) == 0 {
- return nil
- }
-
- // Best-effort cleanup of uploaded image files
- log.Debug(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)
- }
- }
- 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)
+ log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c)
}
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 +336,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 +345,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 +363,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 +377,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 +388,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 +465,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 +491,72 @@ 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 {
- 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))
- },
- }
-}
-
-// 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) {
- 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
- }
+func (r *artistRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) {
var res dbArtists
- err := r.doSearch(ctx, r.selectArtist(ctx, opts), q, &res, r.searchCfg(scope), opts)
- if err != nil {
- return nil, fmt.Errorf("searching artist %q: %w", q, err)
- }
- artists := res.toModels()
- r.hydrateArtwork(ctx, artists)
- return artists, 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
+ if uuid.Validate(q) == nil {
+ err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, &res)
+ if err != nil {
+ return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err)
+ }
+ } else {
+ // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
+ err := r.doSearch(r.selectArtist(options...), q, offset, size, &res, "artist.id",
+ "sum(json_extract(stats, '$.total.m')) desc", "name")
+ if err != nil {
+ return nil, fmt.Errorf("searching artist by query %q: %w", q, err)
}
- 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
+ return res.toModels(), nil
}
-// requestedLibraryIDs extracts the []int from an Eq{"library_id": ids} filter, or nil if filter is
-// not that shape.
-func (r *artistRepository) requestedLibraryIDs(filter Sqlizer) []int {
- eq, ok := filter.(Eq)
- if !ok {
- return nil
- }
- ids, _ := eq["library_id"].([]int)
- return ids
+func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
+ return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
-// 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) Read(id string) (interface{}, error) {
+ return r.Get(id)
}
-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) (interface{}, 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() interface{} {
+ 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..dfaf499ac 100644
--- a/persistence/artist_repository_test.go
+++ b/persistence/artist_repository_test.go
@@ -3,20 +3,13 @@ package persistence
import (
"context"
"encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "sync"
"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/request"
"github.com/navidrome/navidrome/utils"
- "github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -50,11 +43,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 +106,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 +184,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(2)))
})
})
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,48 +222,36 @@ 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).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("F"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
- Expect(idx[2].ID).To(Equal("R"))
- Expect(idx[2].Artists).To(HaveLen(1))
- Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
- Expect(idx[3].ID).To(Equal("S"))
- Expect(idx[3].Artists).To(HaveLen(1))
- Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
// 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).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
- Expect(idx[2].ID).To(Equal("R"))
- Expect(idx[2].Artists).To(HaveLen(1))
- Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
- Expect(idx[3].ID).To(Equal("S"))
- Expect(idx[3].Artists).To(HaveLen(1))
- Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
})
})
@@ -510,47 +262,35 @@ 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).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
- Expect(idx[2].ID).To(Equal("R"))
- Expect(idx[2].Artists).To(HaveLen(1))
- Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
- Expect(idx[3].ID).To(Equal("S"))
- Expect(idx[3].Artists).To(HaveLen(1))
- Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
// 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).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
Expect(idx[0].Artists).To(HaveLen(1))
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
Expect(idx[1].ID).To(Equal("K"))
Expect(idx[1].Artists).To(HaveLen(1))
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
- Expect(idx[2].ID).To(Equal("R"))
- Expect(idx[2].Artists).To(HaveLen(1))
- Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
- Expect(idx[3].ID).To(Equal("S"))
- Expect(idx[3].Artists).To(HaveLen(1))
- Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
})
})
@@ -564,14 +304,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 +320,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 +338,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 +359,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,133 +367,79 @@ 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))
+ Expect(idx).To(HaveLen(2))
// 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
})
})
})
- Describe("Filters", func() {
- var artistWithoutAnnotation model.Artist
-
- BeforeEach(func() {
- // Create artist without any annotation
- artistWithoutAnnotation = model.Artist{ID: "no-annotation-artist", Name: "No Annotation Artist"}
- err := createArtistWithLibrary(ctx, 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}))
- }
- })
-
- Describe("starred", func() {
- It("false includes items without annotations", func() {
- res, err := repo.ReadAll(ctx, rest.QueryOptions{
- Filters: map[string]any{"starred": "false"},
- })
- Expect(err).ToNot(HaveOccurred())
- artists := res
-
- var found bool
- for _, a := range artists {
- if a.ID == artistWithoutAnnotation.ID {
- found = true
- break
- }
- }
- Expect(found).To(BeTrue(), "Artist without annotation should be included in starred=false filter")
- })
-
- It("true excludes items without annotations", func() {
- res, err := repo.ReadAll(ctx, rest.QueryOptions{
- Filters: map[string]any{"starred": "true"},
- })
- Expect(err).ToNot(HaveOccurred())
- artists := res
-
- for _, a := range artists {
- Expect(a.ID).ToNot(Equal(artistWithoutAnnotation.ID))
- }
- })
- })
- })
-
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", 0, 10)
Expect(err).ToNot(HaveOccurred())
if shouldFind {
@@ -765,43 +451,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", 0, 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", 0, 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", 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("The Beatles"))
@@ -813,113 +499,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", 0, 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 +521,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 +541,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 +561,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 +574,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)))
+ Expect(count).To(Equal(int64(2)))
- artists, err := repo.GetAll(ctx)
+ artists, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
- Expect(artists).To(HaveLen(4))
+ Expect(artists).To(HaveLen(2))
- exists, err := repo.Exists(ctx, artistBeatles.ID)
+ exists, err := repo.Exists(artistBeatles.ID)
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
})
@@ -1003,25 +597,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
+ Expect(artists).To(HaveLen(3)) // 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", 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@@ -1030,7 +624,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 +631,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", 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
- results, err = restrictedRepo.Search(restrictedCtx, "Kraftwerk", model.QueryOptions{Max: 10})
+ results, err = restrictedRepo.Search("Kraftwerk", 0, 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,270 +687,86 @@ 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
+ Expect(count).To(Equal(int64(2))) // Beatles and Kraftwerk
})
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))
+ Expect(artists).To(HaveLen(2))
var names []string
for _, artist := range artists {
names = append(names, artist.Name)
}
- Expect(names).To(ContainElements("The Beatles", "Kraftwerk", "シートベルツ", "The Roots"))
+ Expect(names).To(ContainElements("The Beatles", "Kraftwerk"))
})
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))
+ Expect(idx).To(HaveLen(2))
// 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())
- })
- })
- })
-
- Describe("purgeEmpty", func() {
- var repo *artistRepository
- var tmpDir string
-
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- tmpDir = GinkgoT().TempDir()
- conf.Server.DataFolder = conf.NewDir(tmpDir)
-
- ctx = request.WithUser(ctx, adminUser)
- repo = NewArtistRepository(GetDBXBuilder()).(*artistRepository)
- })
-
- // Helper to create an artist image file on disk and return its path
- createImageFile := func(filename string) string {
- dir := filepath.Join(tmpDir, consts.ArtworkFolder, consts.EntityArtist)
- Expect(os.MkdirAll(dir, 0755)).To(Succeed())
- path := filepath.Join(dir, filename)
- Expect(os.WriteFile(path, []byte("fake image data"), 0600)).To(Succeed())
- return path
- }
-
- 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())
- imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg")
-
- Expect(repo.purgeEmpty(ctx)).To(Succeed())
-
- // Artist should be gone from DB
- exists, err := repo.Exists(ctx, "orphan-with-image")
- Expect(err).ToNot(HaveOccurred())
- Expect(exists).To(BeFalse())
-
- // Image file should be removed from disk
- _, err = os.Stat(imgPath)
- Expect(os.IsNotExist(err)).To(BeTrue())
- })
-
- 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.purgeEmpty(ctx)).To(Succeed())
-
- // Artist should be gone from DB
- exists, err := repo.Exists(ctx, "orphan-no-file")
- Expect(err).ToNot(HaveOccurred())
- Expect(exists).To(BeFalse())
- })
-
- 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())
- 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").
- 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"})
- })
-
- Expect(repo.purgeEmpty(ctx)).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)
- Expect(err).ToNot(HaveOccurred())
- Expect(ids).To(HaveLen(1))
-
- // Image file should still be on disk
- _, err = os.Stat(imgPath)
- 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..7e1144753 100644
--- a/persistence/collation_test.go
+++ b/persistence/collation_test.go
@@ -32,7 +32,6 @@ var _ = Describe("Collation", func() {
Entry("media_file.sort_title", "media_file", "sort_title"),
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
- Entry("playlist.name", "playlist", "name"),
Entry("radio.name", "radio", "name"),
Entry("user.name", "user", "name"),
)
@@ -50,8 +49,10 @@ 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"),
Entry("user.user_name", "user", "user_name collate nocase"),
)
diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go
deleted file mode 100644
index d2f817438..000000000
--- a/persistence/criteria_sql.go
+++ /dev/null
@@ -1,904 +0,0 @@
-package persistence
-
-import (
- "errors"
- "fmt"
- "maps"
- "reflect"
- "slices"
- "strconv"
- "strings"
- "time"
-
- "github.com/Masterminds/squirrel"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/criteria"
- "golang.org/x/text/unicode/norm"
-)
-
-type smartPlaylistJoinType int
-
-const (
- smartPlaylistJoinNone smartPlaylistJoinType = 0
- smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota
- smartPlaylistJoinArtistAnnotation
- smartPlaylistJoinAlbum
-)
-
-func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool {
- return j&other != 0
-}
-
-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.
-}
-
-type smartPlaylistCriteria struct {
- criteria.Criteria
- owner model.User
-}
-
-func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria {
- cSQL := smartPlaylistCriteria{Criteria: c}
- for _, opt := range opts {
- opt(&cSQL)
- }
- return cSQL
-}
-
-func withSmartPlaylistOwner(owner model.User) func(*smartPlaylistCriteria) {
- return func(c *smartPlaylistCriteria) {
- c.owner = owner
- }
-}
-
-var smartPlaylistFields = map[string]smartPlaylistField{
- "title": {expr: "media_file.title"},
- "album": {expr: "media_file.album"},
- "hascoverart": {expr: "media_file.has_cover_art"},
- "tracknumber": {expr: "media_file.track_number"},
- "discnumber": {expr: "media_file.disc_number"},
- "year": {expr: "media_file.year"},
- "date": {expr: "media_file.date"},
- "originalyear": {expr: "media_file.original_year"},
- "originaldate": {expr: "media_file.original_date"},
- "releaseyear": {expr: "media_file.release_year"},
- "releasedate": {expr: "media_file.release_date"},
- "size": {expr: "media_file.size"},
- "compilation": {expr: "media_file.compilation"},
- "missing": {expr: "media_file.missing"},
- "explicitstatus": {expr: "media_file.explicit_status"},
- "dateadded": {expr: "media_file.created_at"},
- "datemodified": {expr: "media_file.updated_at"},
- "discsubtitle": {expr: "media_file.disc_subtitle"},
- "comment": {expr: "media_file.comment"},
- "lyrics": {expr: "media_file.lyrics", emptyValues: []string{"[]"}},
- "sorttitle": {expr: "media_file.sort_title"},
- "sortalbum": {expr: "media_file.sort_album_name"},
- "sortartist": {expr: "media_file.sort_artist_name"},
- "sortalbumartist": {expr: "media_file.sort_album_artist_name"},
- "albumcomment": {expr: "media_file.mbz_album_comment"},
- "catalognumber": {expr: "media_file.catalog_num"},
- "filepath": {expr: "media_file.path"},
- "filetype": {expr: "media_file.suffix"},
- "codec": {expr: "media_file.codec"},
- "duration": {expr: "media_file.duration"},
- "bitrate": {expr: "media_file.bit_rate"},
- "bitdepth": {expr: "media_file.bit_depth"},
- "samplerate": {expr: "media_file.sample_rate"},
- "bpm": {expr: "media_file.bpm"},
- "channels": {expr: "media_file.channels"},
- "loved": {expr: "annotation.starred", coalesceDefault: 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},
- "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},
- "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},
- "artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation},
- "artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation},
- "artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation},
- "mbz_album_id": {expr: "media_file.mbz_album_id"},
- "mbz_album_artist_id": {expr: "media_file.mbz_album_artist_id"},
- "mbz_artist_id": {expr: "media_file.mbz_artist_id"},
- "mbz_recording_id": {expr: "media_file.mbz_recording_id"},
- "mbz_release_track_id": {expr: "media_file.mbz_release_track_id"},
- "mbz_release_group_id": {expr: "media_file.mbz_release_group_id"},
- "rgalbumgain": {expr: "media_file.rg_album_gain"},
- "rgalbumpeak": {expr: "media_file.rg_album_peak"},
- "rgtrackgain": {expr: "media_file.rg_track_gain"},
- "rgtrackpeak": {expr: "media_file.rg_track_peak"},
- "library_id": {expr: "media_file.library_id"},
- "random": {order: "random()"},
-}
-
-func (c smartPlaylistCriteria) where() (squirrel.Sqlizer, error) {
- if c.Criteria.Expression == nil {
- return squirrel.Expr("1 = 1"), nil
- }
- return c.exprSQL(c.Criteria.Expression)
-}
-
-func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
- switch e := expr.(type) {
- case criteria.All:
- and := squirrel.And{}
- for _, child := range e {
- cond, err := c.exprSQL(child)
- if err != nil {
- return nil, err
- }
- and = append(and, cond)
- }
- return mergeNegatedJsonConds(and), nil
- case criteria.Any:
- or := squirrel.Or{}
- for _, child := range e {
- cond, err := c.exprSQL(child)
- if err != nil {
- return nil, err
- }
- or = append(or, cond)
- }
- return mergeJsonConds(or), nil
- case criteria.Is:
- return comparisonExpr(e, cmpEq)
- case criteria.IsNot:
- return isNotExpr(e)
- case criteria.Gt:
- return comparisonExpr(e, cmpGt)
- case criteria.Lt:
- return comparisonExpr(e, cmpLt)
- case criteria.Before:
- return comparisonExpr(e, cmpLt)
- case criteria.After:
- return comparisonExpr(e, cmpGt)
- case criteria.Contains:
- return likeExpr(e, "%%%v%%", false)
- case criteria.NotContains:
- return likeExpr(e, "%%%v%%", true)
- case criteria.StartsWith:
- return likeExpr(e, "%v%%", false)
- case criteria.EndsWith:
- return likeExpr(e, "%%%v", false)
- case criteria.InTheRange:
- return rangeExpr(e)
- case criteria.InTheLast:
- return periodExpr(e, false)
- case criteria.NotInTheLast:
- return periodExpr(e, true)
- case criteria.InPlaylist:
- return c.inList(e, false)
- case criteria.NotInPlaylist:
- return c.inList(e, true)
- case criteria.IsMissing:
- return missingExpr(e, true)
- case criteria.IsPresent:
- return missingExpr(e, false)
- default:
- return nil, fmt.Errorf("unknown criteria expression type %T", expr)
- }
-}
-
-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)
-}
-
-func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) {
- field, value, info, ok := singleField(values)
- if !ok {
- if len(values) != 1 {
- return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field")
- }
- return nil, fmt.Errorf("invalid field in criteria: %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
-
- 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 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
- }
- }
- fields, err := sqlFields(values)
- if err != nil {
- return nil, err
- }
- if negate {
- lk := squirrel.NotLike{}
- for field, value := range fields {
- lk[field] = fmt.Sprintf(pattern, value)
- }
- return lk, nil
- }
- lk := squirrel.Like{}
- for field, value := range fields {
- lk[field] = fmt.Sprintf(pattern, value)
- }
- 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)
- if err != nil {
- return nil, err
- }
- high, err := comparisonExpr(map[string]any{field: s.Index(1).Interface()}, cmpLe)
- if err != nil {
- return nil, err
- }
- return squirrel.And{low, high}, nil
-}
-
-func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
- fields, err := sqlFields(values)
- if err != nil {
- return nil, err
- }
- var field string
- var value any
- for f, v := range fields {
- field, value = f, v
- break
- }
- days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64)
- if err != nil {
- return nil, err
- }
- firstDate := startOfPeriod(days, time.Now())
- if negate {
- return squirrel.Or{
- squirrel.Lt{field: firstDate},
- squirrel.Eq{field: nil},
- }, nil
- }
- return squirrel.Gt{field: firstDate}, nil
-}
-
-func startOfPeriod(numDays int64, from time.Time) string {
- return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
-}
-
-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")
- }
- filters := squirrel.And{condition}
- if !c.owner.IsAdmin {
- if c.owner.ID == "" {
- filters = append(filters, squirrel.Eq{"playlist.public": 1})
- } else {
- filters = append(filters, squirrel.Or{
- squirrel.Eq{"playlist.public": 1},
- squirrel.Eq{"playlist.owner_id": c.owner.ID},
- })
- }
- }
- subQuery := squirrel.Select("media_file_id").
- From("playlist_tracks pl").
- LeftJoin("playlist on pl.playlist_id = playlist.id").
- Where(filters)
- subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
- if err != nil {
- return nil, err
- }
- if negate {
- return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil
- }
- 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}
- }
- return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate}
-}
-
-type tagCond struct {
- tag string
- numeric bool
- cond squirrel.Sqlizer
- not bool
-}
-
-func (e tagCond) ToSql() (string, []any, error) {
- var cond string
- var args []any
- var err error
- if e.cond != nil {
- cond, args, err = e.cond.ToSql()
- if e.numeric {
- cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
- }
- cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond)
- } else {
- cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag)
- }
- if e.not {
- cond = "not " + cond
- }
- return cond, args, err
-}
-
-type roleCond struct {
- role string
- cond squirrel.Sqlizer
- not bool
-}
-
-func (e roleCond) ToSql() (string, []any, error) {
- var cond string
- var args []any
- 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...)
- } 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}
- }
- 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
-}
-
-func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) {
- if len(values) != 1 {
- return "", nil, criteria.FieldInfo{}, false
- }
- for field, value := range values {
- info, ok := criteria.LookupField(field)
- return field, value, info, ok
- }
- return "", nil, criteria.FieldInfo{}, false
-}
-
-func sqlFields(values map[string]any) (map[string]any, error) {
- fields := make(map[string]any, len(values))
- for field, value := range values {
- info, ok := criteria.LookupField(field)
- if !ok {
- return nil, fmt.Errorf("invalid field in criteria: %s", field)
- }
- if info.IsTag || info.IsRole {
- return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field)
- }
- sqlField, ok := fieldExpr(info.Name())
- if !ok || sqlField == "" {
- return nil, fmt.Errorf("invalid field in criteria: %s", field)
- }
- fields[sqlField] = value
- }
- return fields, nil
-}
-
-func fieldExpr(name string) (string, bool) {
- field, ok := smartPlaylistFields[strings.ToLower(name)]
- 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 {
- return smartPlaylistJoinNone
- }
- field, ok := smartPlaylistFields[info.Name()]
- if !ok {
- return smartPlaylistJoinNone
- }
- return field.joinType
-}
-
-func (c smartPlaylistCriteria) expressionJoins() smartPlaylistJoinType {
- var joins smartPlaylistJoinType
- _ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error {
- for field := range criteria.Fields(expr) {
- joins |= fieldJoinType(field)
- }
- return nil
- })
- return joins
-}
-
-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 {
- sortFields := c.Criteria.OrderByFields()
- parts := make([]string, 0, len(sortFields))
- for _, sf := range sortFields {
- mapped, ok := sortExpr(sf.Field)
- if !ok {
- continue
- }
- dir := "asc"
- if sf.Desc {
- dir = "desc"
- }
- parts = append(parts, mapped+" "+dir)
- }
- return strings.Join(parts, ", ")
-}
-
-func sortExpr(sortField string) (string, bool) {
- info, ok := criteria.LookupField(sortField)
- if !ok {
- return "", false
- }
- if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" {
- return field.order, true
- }
- var mapped string
- switch {
- case info.IsTag:
- mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')"
- case info.IsRole:
- mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')"
- default:
- field, ok := smartPlaylistFields[info.Name()]
- 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()
- }
- if info.Numeric {
- mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
- }
- return mapped, true
-}
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
deleted file mode 100644
index ef91ec989..000000000
--- a/persistence/criteria_sql_test.go
+++ /dev/null
@@ -1,647 +0,0 @@
-package persistence
-
-import (
- "fmt"
- "strings"
- "time"
-
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/criteria"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-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.AddNumericTags([]string{"rate"})
- })
-
- DescribeTable("expressions",
- func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) {
- 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(expectedSQL))
- Expect(args).To(HaveExactElements(expectedArgs...))
- },
- Entry("all group",
- criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}},
- "(media_file.title LIKE ? AND annotation.rating > ?)", "%love%", 3),
- Entry("any group",
- criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}},
- "(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"),
- Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
- Entry("is bool", criteria.Is{"loved": true}, "annotation.starred = ?", true),
- Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
- Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
- Entry("gt", criteria.Gt{"playCount": 10}, "annotation.play_count > ?", 10),
- Entry("lt", criteria.Lt{"playCount": 10}, "(annotation.play_count < ? OR annotation.play_count IS NULL)", 10),
- Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
- Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
- Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
- Entry("ends with", criteria.EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
- 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("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("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%"),
- Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
- 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%"),
- // 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),
- Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0),
- // isMissing — tags
- Entry("isMissing tag [true]", criteria.IsMissing{"genre": true},
- "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
- Entry("isMissing tag [false]", criteria.IsMissing{"genre": false},
- "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"),
- 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"),
- // isPresent — tags
- Entry("isPresent tag [true]", criteria.IsPresent{"genre": true},
- "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
- Entry("isPresent tag [false]", criteria.IsPresent{"genre": false},
- "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"),
- 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 <> ?)", ""),
- )
-
- Describe("playlist permissions", func() {
- It("allows public or same-owner playlist references for regular users", func() {
- sqlizer, err := newSmartPlaylistCriteria(
- criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
- withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}),
- ).where()
- Expect(err).ToNot(HaveOccurred())
-
- sql, args, err := sqlizer.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(Equal("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 = ? OR playlist.owner_id = ?)))"))
- Expect(args).To(HaveExactElements("deadbeef-dead-beef", 1, "owner-id"))
- })
-
- It("allows all playlist references for admins", func() {
- sqlizer, err := newSmartPlaylistCriteria(
- criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
- withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}),
- ).where()
- Expect(err).ToNot(HaveOccurred())
-
- sql, args, err := sqlizer.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(Equal("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 = ?))"))
- Expect(args).To(HaveExactElements("deadbeef-dead-beef"))
- })
- })
-
- It("builds relative date expressions", func() {
- sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).where()
- Expect(err).ToNot(HaveOccurred())
-
- sql, args, err := sqlizer.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(Equal("annotation.play_date > ?"))
- Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
- })
-
- It("builds negated relative date expressions", func() {
- sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).where()
- Expect(err).ToNot(HaveOccurred())
-
- sql, args, err := sqlizer.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(Equal("(annotation.play_date < ? OR annotation.play_date IS NULL)"))
- Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
- })
-
- It("returns an error for unknown fields", func() {
- _, 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")))
- })
-
- 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")))
- })
-
- It("returns an error when isMissing has a non-boolean value", func() {
- _, 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"))
- })
-
- 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"))
- })
-
- 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"))
- })
-
- 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"))
- })
-
- 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"))
- })
-
- 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"))
- })
-
- 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"))
- })
-
- 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"))
- })
-
- It("ignores invalid sort fields", func() {
- Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).orderBy()).To(Equal("media_file.title asc"))
- })
- })
-
- It("has SQL mappings for all non-tag/non-role criteria fields", func() {
- for _, name := range criteria.AllFieldNames() {
- info, ok := criteria.LookupField(name)
- Expect(ok).To(BeTrue(), "field %q registered but LookupField fails", name)
- if info.IsTag || info.IsRole {
- continue
- }
- _, hasSQLField := smartPlaylistFields[info.Name()]
- Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name())
- }
- })
-
- 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())
- })
-
- It("includes expression-based joins", func() {
- c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
-
- Expect(newSmartPlaylistCriteria(c).expressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
- })
-
- It("detects nested album and artist joins", func() {
- c := criteria.Criteria{Expression: criteria.All{
- criteria.Any{criteria.All{criteria.Is{"albumLoved": true}}},
- criteria.Any{criteria.Gt{"artistPlayCount": 10}},
- }}
-
- joins := newSmartPlaylistCriteria(c).requiredJoins()
- Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
- Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
- })
-
- 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())
- })
- })
-})
diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go
deleted file mode 100644
index a757901d3..000000000
--- a/persistence/e2e/e2e_suite_test.go
+++ /dev/null
@@ -1,350 +0,0 @@
-package e2e
-
-import (
- "context"
- "encoding/json"
- "os"
- "path/filepath"
- "sort"
- "testing"
- "testing/fstest"
- "time"
-
- "github.com/Masterminds/squirrel"
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
- "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/criteria"
- "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 TestSmartPlaylistE2E(t *testing.T) {
- tests.Init(t, false)
- defer db.Close(t.Context())
- log.SetLevel(log.LevelFatal)
- RegisterFailHandler(Fail)
- RunSpecs(t, "Smart Playlist E2E Suite")
-}
-
-type _t = map[string]any
-
-var template = storagetest.Template
-var track = storagetest.Track
-
-var (
- ctx context.Context
- ds *tests.MockDataStore
- lib model.Library
-
- dbFilePath string
- snapshotPath string
- snapshotTables []string
-
- adminUser = model.User{
- ID: "sp-test-user-1",
- UserName: "sptestuser",
- Name: "SP Test User",
- IsAdmin: true,
- }
-
- regularUser = model.User{
- ID: "sp-test-user-2",
- UserName: "spotheruser",
- Name: "SP Other User",
- IsAdmin: false,
- }
-)
-
-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",
- })
- ledZepIV := template(_t{
- "albumartist": "Led Zeppelin",
- "artist": "Led Zeppelin",
- "album": "IV",
- "year": 1971,
- })
- kindOfBlue := template(_t{
- "albumartist": "Miles Davis",
- "artist": "Miles Davis",
- "album": "Kind of Blue",
- "year": 1959,
- "genre": "Jazz",
- "composer": "Miles Davis",
- })
- nightAtOpera := template(_t{
- "albumartist": "Queen",
- "artist": "Queen",
- "album": "A Night at the Opera",
- "year": 1975,
- "genre": "Rock",
- })
- electricLadyland := template(_t{
- "albumartist": "Jimi Hendrix",
- "artist": "Jimi Hendrix",
- "album": "Electric Ladyland",
- "year": 1968,
- "genre": "Rock;Blues",
- })
- newsOfWorld := template(_t{
- "albumartist": "Queen",
- "artist": "Queen",
- "album": "News of the World",
- "year": 1977,
- "genre": "Rock;Pop",
- "compilation": "1",
- })
-
- 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"})),
- "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
- "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"})),
- "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})),
- "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What",
- _t{"bpm": 136})),
- "Rock/Queen/A Night at the Opera/01 - Bohemian Rhapsody.mp3": nightAtOpera(track(1, "Bohemian Rhapsody",
- _t{"composer": "Freddie Mercury", "bpm": 72})),
- "Rock/Jimi Hendrix/Electric Ladyland/01 - All Along the Watchtower.mp3": electricLadyland(track(1, "All Along the Watchtower",
- _t{"composer": "Bob Dylan", "bpm": 112})),
- "Rock/Queen/News of the World/01 - We Are the Champions.mp3": newsOfWorld(track(1, "We Are the Champions",
- _t{"composer": "Freddie Mercury", "bpm": 64})),
- })
- storagetest.Register("fake", &fs)
-}
-
-func findMediaFileByTitle(title string) string {
- mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
- Filters: squirrel.Eq{"media_file.title": title},
- })
- Expect(err).ToNot(HaveOccurred())
- Expect(mfs).To(HaveLen(1), "expected exactly one media file with title %q", title)
- return mfs[0].ID
-}
-
-func evaluateRule(jsonRule string) []string {
- titles := evaluateRuleOrderedAs(adminUser, jsonRule)
- sort.Strings(titles)
- return titles
-}
-
-func evaluateRuleOrdered(jsonRule string) []string {
- return evaluateRuleOrderedAs(adminUser, jsonRule)
-}
-
-func evaluateRuleAs(owner model.User, jsonRule string) []string {
- titles := evaluateRuleOrderedAs(owner, jsonRule)
- sort.Strings(titles)
- return titles
-}
-
-func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string {
- userCtx := request.WithUser(GinkgoT().Context(), owner)
- var rules criteria.Criteria
- err := json.Unmarshal([]byte(jsonRule), &rules)
- Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule)
-
- pls := &model.Playlist{
- Name: "test-smart-playlist",
- OwnerID: owner.ID,
- Rules: &rules,
- }
- err = ds.Playlist().Put(userCtx, pls)
- Expect(err).ToNot(HaveOccurred())
-
- loaded, err := ds.Playlist().GetWithTracks(userCtx, pls.ID, true, false)
- Expect(err).ToNot(HaveOccurred())
-
- titles := make([]string, len(loaded.Tracks))
- for i, t := range loaded.Tracks {
- titles[i] = t.Title
- }
- return titles
-}
-
-func createPlaylist(owner model.User, public bool, titles ...string) string {
- pls := &model.Playlist{
- Name: "ref-playlist",
- OwnerID: owner.ID,
- Public: public,
- }
- for _, title := range titles {
- mfID := findMediaFileByTitle(title)
- pls.AddMediaFilesByID([]string{mfID})
- }
- Expect(ds.Playlist().Put(ctx, pls)).To(Succeed())
- return pls.ID
-}
-
-func createPublicPlaylist(owner model.User, titles ...string) string {
- return createPlaylist(owner, true, titles...)
-}
-
-func createPrivatePlaylist(owner model.User, titles ...string) string {
- return createPlaylist(owner, false, titles...)
-}
-
-func createPublicSmartPlaylist(owner model.User, jsonRule string) string {
- return createSmartPlaylist(owner, true, jsonRule)
-}
-
-func createPrivateSmartPlaylist(owner model.User, jsonRule string) string {
- return createSmartPlaylist(owner, false, jsonRule)
-}
-
-func createSmartPlaylist(owner model.User, public bool, jsonRule string) string {
- var rules criteria.Criteria
- Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed())
- pls := &model.Playlist{
- Name: "ref-smart-playlist",
- OwnerID: owner.ID,
- Public: public,
- Rules: &rules,
- }
- Expect(ds.Playlist().Put(ctx, pls)).To(Succeed())
- return pls.ID
-}
-
-var _ = BeforeSuite(func() {
- ctx = request.WithUser(GinkgoT().Context(), adminUser)
- tmpDir := GinkgoT().TempDir()
- dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db")
- snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot")
- conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL"
- db.Db().SetMaxOpenConns(1)
-
- conf.Server.MusicFolder = "fake:///music"
- conf.Server.DevExternalScanner = false
- conf.Server.SmartPlaylistRefreshDelay = 0
-
- db.Init(ctx)
-
- initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())}
-
- userWithPass := adminUser
- userWithPass.NewPassword = "password"
- Expect(initDS.User().Put(ctx, &userWithPass)).To(Succeed())
-
- regularUserWithPass := regularUser
- regularUserWithPass.NewPassword = "password"
- Expect(initDS.User().Put(ctx, ®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())
-
- loadedUser, err := initDS.User().FindByUsername(ctx, adminUser.UserName)
- Expect(err).ToNot(HaveOccurred())
- adminUser.Libraries = loadedUser.Libraries
-
- loadedOther, err := initDS.User().FindByUsername(ctx, 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())
- _, 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())
- for range 10 {
- Expect(ds.MediaFile().IncPlayCount(ctx, comeTogetherID, time.Now())).To(Succeed())
- }
- Expect(ds.MediaFile().IncPlayCount(ctx, 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())
- defer rows.Close()
- for rows.Next() {
- var name string
- Expect(rows.Scan(&name)).To(Succeed())
- snapshotTables = append(snapshotTables, name)
- }
- Expect(rows.Err()).ToNot(HaveOccurred())
-
- _, 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())
-})
-
-var _ = AfterSuite(func() {
- db.Close(ctx)
-})
-
-func restoreDB() {
- sqlDB := db.Db()
-
- _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF")
- Expect(err).ToNot(HaveOccurred())
- defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }()
-
- _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath)
- Expect(err).ToNot(HaveOccurred())
- defer func() { _, _ = sqlDB.Exec("DETACH DATABASE snapshot") }()
-
- _, err = sqlDB.Exec("BEGIN TRANSACTION")
- Expect(err).ToNot(HaveOccurred())
- defer func() { _, _ = sqlDB.Exec("ROLLBACK") }()
-
- for _, table := range snapshotTables {
- _, 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("COMMIT")
- Expect(err).ToNot(HaveOccurred())
-}
-
-func setupTestDB() {
- ctx = request.WithUser(GinkgoT().Context(), adminUser)
- DeferCleanup(configtest.SetupConfig())
- conf.Server.MusicFolder = "fake:///music"
- conf.Server.DevExternalScanner = false
- conf.Server.SmartPlaylistRefreshDelay = 0
-
- restoreDB()
- ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
-}
diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go
deleted file mode 100644
index 51442c822..000000000
--- a/persistence/e2e/smartplaylist_test.go
+++ /dev/null
@@ -1,468 +0,0 @@
-package e2e
-
-import (
- "github.com/navidrome/navidrome/tests"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
- "github.com/sirupsen/logrus"
-)
-
-var _ = Describe("Smart Playlists", func() {
- BeforeEach(func() {
- setupTestDB()
- })
-
- Describe("String fields", func() {
- It("matches by exact title", func() {
- results := evaluateRule(`{"all":[{"is":{"title":"Something"}}]}`)
- Expect(results).To(ConsistOf("Something"))
- })
-
- It("matches by title contains", func() {
- results := evaluateRule(`{"all":[{"contains":{"title":"the"}}]}`)
- Expect(results).To(ConsistOf("Come Together", "All Along the Watchtower", "We Are the Champions"))
- })
-
- It("matches by artist startsWith", func() {
- results := evaluateRule(`{"all":[{"startsWith":{"artist":"Led"}}]}`)
- Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
- })
-
- It("matches by title isNot", func() {
- results := evaluateRule(`{"all":[{"isNot":{"title":"Something"}},{"is":{"artist":"The Beatles"}}]}`)
- Expect(results).To(ConsistOf("Come Together"))
- })
-
- It("matches by artist endsWith", func() {
- results := evaluateRule(`{"all":[{"endsWith":{"artist":"Davis"}}]}`)
- Expect(results).To(ConsistOf("So What"))
- })
- })
-
- Describe("Numeric fields", func() {
- It("matches by year greater than", func() {
- results := evaluateRule(`{"all":[{"gt":{"year":1970}}]}`)
- Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "We Are the Champions"))
- })
-
- It("matches by year less than", func() {
- results := evaluateRule(`{"all":[{"lt":{"year":1969}}]}`)
- Expect(results).To(ConsistOf("So What", "All Along the Watchtower"))
- })
-
- It("matches by BPM in range", func() {
- results := evaluateRule(`{"all":[{"inTheRange":{"bpm":[100,130]}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Something", "All Along the Watchtower"))
- })
- })
-
- Describe("Boolean fields", func() {
- It("matches compilations", func() {
- results := evaluateRule(`{"all":[{"is":{"compilation":true}}]}`)
- Expect(results).To(ConsistOf("We Are the Champions"))
- })
-
- It("matches non-compilations", func() {
- results := evaluateRule(`{"all":[{"is":{"compilation":false}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower"))
- })
- })
-
- Describe("File type fields", func() {
- It("matches by filetype", func() {
- results := evaluateRule(`{"all":[{"is":{"filetype":"flac"}}]}`)
- Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
- })
- })
-
- Describe("Multi-valued tags", func() {
- It("matches tracks with Blues genre", func() {
- results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower"))
- })
-
- It("excludes tracks with Rock genre", func() {
- results := evaluateRule(`{"all":[{"isNot":{"genre":"Rock"}}]}`)
- Expect(results).To(ConsistOf("So What"))
- })
-
- It("matches genre contains", func() {
- results := evaluateRule(`{"all":[{"contains":{"genre":"ol"}}]}`)
- Expect(results).To(ConsistOf("Stairway To Heaven"))
- })
-
- It("matches tracks with Pop genre", func() {
- results := evaluateRule(`{"all":[{"is":{"genre":"Pop"}}]}`)
- Expect(results).To(ConsistOf("We Are the Champions"))
- })
-
- It("matches genre startsWith", func() {
- results := evaluateRule(`{"all":[{"startsWith":{"genre":"Ro"}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
- "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
- })
- })
-
- Describe("Participants", func() {
- It("matches by exact composer", func() {
- results := evaluateRule(`{"all":[{"is":{"composer":"Harrison"}}]}`)
- Expect(results).To(ConsistOf("Something"))
- })
-
- It("matches by composer contains", func() {
- results := evaluateRule(`{"all":[{"contains":{"composer":"Plant"}}]}`)
- Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
- })
-
- It("matches by composer isNot", func() {
- results := evaluateRule(`{"all":[{"isNot":{"composer":"Freddie Mercury"}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "All Along the Watchtower"))
- })
-
- It("matches by composer endsWith", func() {
- results := evaluateRule(`{"all":[{"endsWith":{"composer":"Mercury"}}]}`)
- Expect(results).To(ConsistOf("Bohemian Rhapsody", "We Are the Champions"))
- })
- })
-
- Describe("Annotations", func() {
- It("matches starred tracks", func() {
- results := evaluateRule(`{"all":[{"is":{"loved":true}}]}`)
- Expect(results).To(ConsistOf("Come Together", "So What"))
- })
-
- It("matches unstarred tracks", func() {
- results := evaluateRule(`{"all":[{"is":{"loved":false}}]}`)
- Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
- })
-
- It("matches by rating greater than", func() {
- results := evaluateRule(`{"all":[{"gt":{"rating":3}}]}`)
- Expect(results).To(ConsistOf("Bohemian Rhapsody"))
- })
-
- It("matches by rating greater than or equal via inTheRange", func() {
- results := evaluateRule(`{"all":[{"inTheRange":{"rating":[3,5]}}]}`)
- Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody"))
- })
-
- It("matches by play count greater than", func() {
- results := evaluateRule(`{"all":[{"gt":{"playcount":5}}]}`)
- Expect(results).To(ConsistOf("Come Together"))
- })
-
- It("matches by play count greater than zero", func() {
- results := evaluateRule(`{"all":[{"gt":{"playcount":0}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Black Dog"))
- })
- })
-
- Describe("Negated string operators", func() {
- It("matches by title notContains", func() {
- results := evaluateRule(`{"all":[{"notContains":{"title":"the"}}]}`)
- Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody"))
- })
- })
-
- Describe("Date/time fields", func() {
- It("matches dateAdded before a far-future date", func() {
- results := evaluateRule(`{"all":[{"before":{"dateadded":"2099-01-01"}}]}`)
- 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 lastPlayed inTheLast 1 day", func() {
- results := evaluateRule(`{"all":[{"inTheLast":{"lastplayed":1}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Black Dog"))
- })
-
- It("matches lastPlayed notInTheLast (far future)", func() {
- results := evaluateRule(`{"all":[{"notInTheLast":{"lastplayed":99999}}]}`)
- Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "So What",
- "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
- })
-
- It("matches dateLoved after a past date", func() {
- results := evaluateRule(`{"all":[{"after":{"dateloved":"2020-01-01"}}]}`)
- Expect(results).To(ConsistOf("Come Together", "So What"))
- })
-
- It("matches dateRated after a past date", func() {
- results := evaluateRule(`{"all":[{"after":{"daterated":"2020-01-01"}}]}`)
- Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody"))
- })
-
- It("matches dateAdded inTheLast 1 day", func() {
- results := evaluateRule(`{"all":[{"inTheLast":{"dateadded":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("resolves recordingdate alias to the date column", func() {
- results := evaluateRule(`{"all":[{"is":{"recordingdate":"1959"}}]}`)
- Expect(results).To(ConsistOf("So What"))
- })
- })
-
- 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}}]}`)
- Expect(results).To(ConsistOf("Black Dog"))
- })
-
- It("matches with ANY (OR)", func() {
- results := evaluateRule(`{"any":[{"is":{"genre":"Jazz"}},{"is":{"compilation":true}}]}`)
- Expect(results).To(ConsistOf("So What", "We Are the Champions"))
- })
-
- It("matches nested all/any", func() {
- results := evaluateRule(`{"all":[{"any":[{"is":{"genre":"Blues"}},{"is":{"genre":"Jazz"}}]},{"gt":{"year":1960}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower"))
- })
- })
-
- Describe("Sorting and limits", func() {
- It("returns tracks sorted by year descending with limit", func() {
- results := evaluateRuleOrdered(`{"all":[{"gt":{"year":0}}],"sort":"year","order":"desc","limit":2}`)
- Expect(results).To(Equal([]string{"We Are the Champions", "Bohemian Rhapsody"}))
- })
-
- It("returns tracks sorted by title ascending", func() {
- results := evaluateRuleOrdered(`{"all":[{"is":{"genre":"Blues"}}],"sort":"title","order":"asc"}`)
- Expect(results).To(Equal([]string{"All Along the Watchtower", "Black Dog", "Come Together"}))
- })
- })
-
- Describe("Combined real-world patterns", func() {
- It("matches genre filter with exclusion and year range", func() {
- results := evaluateRuleOrdered(`{
- "all":[
- {"any":[
- {"is":{"genre":"Blues"}},
- {"is":{"genre":"Folk"}}
- ]},
- {"isNot":{"genre":"Jazz"}},
- {"gt":{"year":1965}}
- ],
- "sort":"-year,title"
- }`)
- Expect(results).To(Equal([]string{"Black Dog", "Stairway To Heaven", "Come Together", "All Along the Watchtower"}))
- })
- })
-
- Describe("Playlist operators", func() {
- It("matches tracks in a public regular playlist", func() {
- refID := createPublicPlaylist(adminUser, "Come Together", "So What")
- results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
- Expect(results).To(ConsistOf("Come Together", "So What"))
- })
-
- It("matches tracks not in a public regular playlist", func() {
- refID := createPublicPlaylist(adminUser, "Come Together", "So What")
- results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
- Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog",
- "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
- })
-
- It("recursively refreshes a referenced smart playlist owned by the same user", func() {
- smartBID := createPublicSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
- results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
- Expect(results).To(ConsistOf("So What"))
- })
-
- It("does not refresh a referenced smart playlist owned by another user", func() {
- smartBID := createPublicSmartPlaylist(regularUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
- results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
- Expect(results).To(BeEmpty())
- })
-
- It("does not refresh a playlist or its children when an admin views another user's smart playlist", func() {
- smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
- smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
-
- loadedA, err := ds.Playlist().GetWithTracks(ctx, smartAID, true, false)
- Expect(err).ToNot(HaveOccurred())
- Expect(loadedA.Tracks).To(BeEmpty())
- Expect(loadedA.EvaluatedAt).To(BeNil())
-
- loadedB, err := ds.Playlist().Get(ctx, smartBID)
- Expect(err).ToNot(HaveOccurred())
- Expect(loadedB.EvaluatedAt).To(BeNil())
- })
-
- It("matches tracks from a private playlist owned by the same user", func() {
- refID := createPrivatePlaylist(regularUser, "Come Together", "So What")
- results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
- Expect(results).To(ConsistOf("Come Together", "So What"))
- })
-
- It("allows admin-owned smart playlists to reference private playlists owned by other users", func() {
- refID := createPrivatePlaylist(regularUser, "Bohemian Rhapsody")
- results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
- Expect(results).To(ConsistOf("Bohemian Rhapsody"))
- })
-
- It("does not match tracks from a private playlist owned by another regular user", func() {
- refID := createPrivatePlaylist(adminUser, "Come Together", "So What")
- results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
- Expect(results).To(BeEmpty())
- })
-
- It("warns when a referenced playlist is inaccessible to the smart playlist owner", func() {
- hook, cleanup := tests.LogHook()
- defer cleanup()
-
- refID := createPrivatePlaylist(adminUser, "Come Together")
- results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
- "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
-
- Expect(hook.LastEntry()).ToNot(BeNil())
- Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
- Expect(hook.LastEntry().Message).To(Equal("Referenced playlist is not accessible to smart playlist owner"))
- Expect(hook.LastEntry().Data).To(HaveKeyWithValue("childId", refID))
- })
-
- It("matches tracks in a public playlist owned by another user", func() {
- refID := createPublicPlaylist(adminUser, "Bohemian Rhapsody")
- results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
- Expect(results).To(ConsistOf("Bohemian Rhapsody"))
- })
-
- })
-
- Describe("isMissing/isPresent operators", func() {
- It("isMissing finds tracks without grouping tag", func() {
- results := evaluateRule(`{"all":[{"isMissing":{"grouping":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 grouping tag", func() {
- results := evaluateRule(`{"all":[{"isMissing":{"grouping":false}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Something"))
- })
-
- It("isPresent finds tracks with grouping tag", func() {
- results := evaluateRule(`{"all":[{"isPresent":{"grouping":true}}]}`)
- Expect(results).To(ConsistOf("Come Together", "Something"))
- })
-
- It("isPresent false finds tracks without grouping tag", func() {
- results := evaluateRule(`{"all":[{"isPresent":{"grouping":false}}]}`)
- Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
- "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
- })
-
- It("isMissing returns all tracks for a tag nobody has", func() {
- results := evaluateRule(`{"all":[{"isMissing":{"lyricist":true}}]}`)
- 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("isPresent returns all tracks for a role everyone has", func() {
- results := evaluateRule(`{"all":[{"isPresent":{"composer":true}}]}`)
- 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("combines isMissing with other operators", func() {
- results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}},{"is":{"genre":"Blues"}}]}`)
- 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..96a9bae82 100644
--- a/persistence/folder_repository.go
+++ b/persistence/folder_repository.go
@@ -4,11 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
- "iter"
- "maps"
- "path"
"slices"
- "strings"
"time"
. "github.com/Masterminds/squirrel"
@@ -27,14 +23,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,208 +52,77 @@ 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) {
- // If no specific paths, return all folders in the library
- if len(targetPaths) == 0 {
- return r.getFolderUpdateInfoAll(ctx, lib)
- }
-
- // Check if any path is root (return all folders)
- for _, targetPath := range targetPaths {
- if targetPath == "" || targetPath == "." {
- return r.getFolderUpdateInfoAll(ctx, lib)
- }
- }
-
- // Process paths in batches to avoid SQLite's expression tree depth limit (max 1000).
- // Each path generates ~3 conditions, so batch size of 100 keeps us well under the limit.
- const batchSize = 100
- result := make(map[string]model.FolderUpdateInfo)
-
- for batch := range slices.Chunk(targetPaths, batchSize) {
- batchResult, err := r.getFolderUpdateInfoBatch(ctx, lib, batch)
- if err != nil {
- return nil, err
- }
- maps.Copy(result, batchResult)
- }
-
- return result, nil
-}
-
-// 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) {
- where := And{
- Eq{"library_id": lib.ID},
- Eq{"missing": false},
- }
- return r.queryFolderUpdateInfo(ctx, 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) {
- where := And{
- Eq{"library_id": lib.ID},
- Eq{"missing": false},
- }
-
- // Collect folder IDs for exact target folders and path conditions for descendants
- folderIDs := make([]string, 0, len(targetPaths))
- 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, "/"))
-
- // Include the target folder itself by ID
- folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
-
- // Include all descendants: folders whose path field equals or starts with the target path
- // Note: Folder.Path is the directory path, so children have path = targetPath
- pathConditions = append(pathConditions, Eq{"path": cleanPath})
- pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
- }
-
- // Combine conditions: exact folder IDs OR descendant path patterns
- if len(folderIDs) > 0 {
- where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
- } else if len(pathConditions) > 0 {
- where = append(where, pathConditions)
- }
-
- return r.queryFolderUpdateInfo(ctx, 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) GetLastUpdates(lib model.Library) (map[string]model.FolderUpdateInfo, error) {
+ sq := r.newSelect().Columns("id", "updated_at", "hash").Where(Eq{"library_id": lib.ID, "missing": false})
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,36 +130,26 @@ 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)
+ cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query)
if err != nil {
return nil, err
}
- return wrapFolderCursor(cursor), nil
+ return func(yield func(model.Folder, error) bool) {
+ for f, err := range cursor {
+ if !yield(*f.Folder, err) || err != nil {
+ return
+ }
+ }
+ }, 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)
- if err != nil {
- return nil, err
- }
- return wrapFolderCursor(cursor), nil
-}
-
-func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor {
- return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder }))
-}
-
-func (r folderRepository) purgeEmpty(ctx context.Context, libraryIDs ...int) error {
+func (r folderRepository) purgeEmpty() error {
sq := Delete(r.tableName).Where(And{
Eq{"num_audio_files": 0},
Eq{"num_playlists": 0},
@@ -310,16 +157,12 @@ func (r folderRepository) purgeEmpty(ctx context.Context, libraryIDs ...int) err
ConcatExpr("id not in (select parent_id from folder)"),
ConcatExpr("id not in (select folder_id from media_file)"),
})
- // If libraryIDs are specified, only purge folders from those libraries
- 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
deleted file mode 100644
index 4b1c54a3d..000000000
--- a/persistence/folder_repository_test.go
+++ /dev/null
@@ -1,391 +0,0 @@
-package persistence
-
-import (
- "context"
- "errors"
- "fmt"
-
- "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"
-)
-
-var _ = Describe("FolderRepository", func() {
- var repo model.FolderRepository
- var ctx context.Context
- var conn *dbx.DB
- var testLib, otherLib model.Library
-
- BeforeEach(func() {
- ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"})
- conn = GetDBXBuilder()
- repo = newFolderRepository(conn)
-
- // Use existing library ID 1 from test fixtures
- libRepo := NewLibraryRepository(conn)
- lib, err := libRepo.Get(ctx, 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())
- })
-
- AfterEach(func() {
- // Clean up only test folders created by our tests (paths starting with "Test")
- // This prevents interference with fixture data needed by other tests
- _, _ = conn.NewQuery("DELETE FROM folder WHERE library_id = 1 AND path LIKE 'Test%'").Execute()
- _, _ = 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() {
- // Create test folders with unique names to avoid conflicts
- folder1 := model.NewFolder(testLib, "TestGetLastUpdates/Folder1")
- folder2 := model.NewFolder(testLib, "TestGetLastUpdates/Folder2")
-
- err := repo.Put(ctx, folder1)
- Expect(err).ToNot(HaveOccurred())
- err = repo.Put(ctx, folder2)
- Expect(err).ToNot(HaveOccurred())
-
- otherFolder := model.NewFolder(otherLib, "TestOtherLib/Folder")
- err = repo.Put(ctx, otherFolder)
- Expect(err).ToNot(HaveOccurred())
-
- // Query all folders (no target paths) - should only return folders from testLib
- results, err := repo.GetFolderUpdateInfo(ctx, testLib)
- Expect(err).ToNot(HaveOccurred())
- // Should include folders from testLib
- Expect(results).To(HaveKey(folder1.ID))
- Expect(results).To(HaveKey(folder2.ID))
- // Should NOT include folders from other library
- Expect(results).ToNot(HaveKey(otherFolder.ID))
- })
- })
-
- Context("with specific target paths", func() {
- It("returns folder info for existing folders", func() {
- // Create test folders with unique names
- folder1 := model.NewFolder(testLib, "TestSpecific/Rock")
- folder2 := model.NewFolder(testLib, "TestSpecific/Jazz")
- folder3 := model.NewFolder(testLib, "TestSpecific/Classical")
-
- err := repo.Put(ctx, folder1)
- Expect(err).ToNot(HaveOccurred())
- err = repo.Put(ctx, folder2)
- Expect(err).ToNot(HaveOccurred())
- err = repo.Put(ctx, folder3)
- Expect(err).ToNot(HaveOccurred())
-
- // Query specific paths
- results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestSpecific/Rock", "TestSpecific/Classical")
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(2))
-
- // Verify folder IDs are in results
- Expect(results).To(HaveKey(folder1.ID))
- Expect(results).To(HaveKey(folder3.ID))
- Expect(results).ToNot(HaveKey(folder2.ID))
-
- // Verify update info is populated
- Expect(results[folder1.ID].UpdatedAt).ToNot(BeZero())
- Expect(results[folder1.ID].Hash).To(Equal(folder1.Hash))
- })
-
- It("includes all child folders when querying parent", func() {
- // 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())
-
- // Query the parent folder - should return parent and all children
- results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestParent/Music")
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(3))
- Expect(results).To(HaveKey(parent.ID))
- Expect(results).To(HaveKey(child1.ID))
- Expect(results).To(HaveKey(child2.ID))
- Expect(results).ToNot(HaveKey(otherParent.ID))
- })
-
- It("excludes children from other libraries", func() {
- // 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())
-
- // 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())
-
- // Query should only return folders from testLib
- results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestIsolation/Parent")
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(2))
- Expect(results).To(HaveKey(parent.ID))
- Expect(results).To(HaveKey(child.ID))
- Expect(results).ToNot(HaveKey(otherParent.ID))
- Expect(results).ToNot(HaveKey(otherChild.ID))
- })
-
- It("excludes missing children when querying parent", func() {
- // 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())
-
- // Query parent - should only return parent and non-missing child
- results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMissingChild/Parent")
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(2))
- Expect(results).To(HaveKey(parent.ID))
- Expect(results).To(HaveKey(child1.ID))
- Expect(results).ToNot(HaveKey(child2.ID))
- })
-
- It("handles mix of existing and non-existing target paths", func() {
- // 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())
-
- // Query both existing and non-existing paths
- results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMixed/Exists", "TestMixed/DoesNotExist")
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(2))
- Expect(results).To(HaveKey(existingParent.ID))
- Expect(results).To(HaveKey(existingChild.ID))
- })
-
- It("handles empty folder path as root", func() {
- // Test querying for root folder without creating it (fixtures should have one)
- rootFolderID := model.FolderID(testLib, ".")
-
- results, err := repo.GetFolderUpdateInfo(ctx, testLib, "")
- Expect(err).ToNot(HaveOccurred())
- // Should return the root folder if it exists
- if len(results) > 0 {
- Expect(results).To(HaveKey(rootFolderID))
- }
- })
-
- It("returns empty map for non-existent folders", func() {
- results, err := repo.GetFolderUpdateInfo(ctx, testLib, "NonExistent/Path")
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(BeEmpty())
- })
-
- It("skips missing folders", func() {
- // Create a folder and mark it as missing
- folder := model.NewFolder(testLib, "TestMissing/Folder")
- folder.Missing = true
- err := repo.Put(ctx, folder)
- Expect(err).ToNot(HaveOccurred())
-
- results, err := repo.GetFolderUpdateInfo(ctx, 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:
- // it yields a zero-value dbFolder (where Folder is nil) with an error.
- dbErr := fmt.Errorf("database is locked")
- cursor := func(yield func(dbFolder, error) bool) {
- var empty dbFolder // Folder pointer is nil
- yield(empty, dbErr)
- }
-
- // wrapFolderCursor should handle the nil Folder without panicking
- wrappedCursor := wrapFolderCursor(cursor)
- var gotErr error
- Expect(func() {
- for _, err := range wrappedCursor {
- gotErr = err
- }
- }).ToNot(Panic())
- Expect(gotErr).To(HaveOccurred())
- Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Folder"))
- Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
- })
-
- It("yields folders from a valid cursor", func() {
- folder := &model.Folder{ID: "f1", Name: "Test"}
- cursor := func(yield func(dbFolder, error) bool) {
- yield(dbFolder{Folder: folder}, nil)
- }
-
- wrappedCursor := wrapFolderCursor(cursor)
- var folders []model.Folder
- for f, err := range wrappedCursor {
- Expect(err).ToNot(HaveOccurred())
- folders = append(folders, f)
- }
- Expect(folders).To(HaveLen(1))
- 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..5857350a6 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) (interface{}, 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) (interface{}, 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() interface{} {
+ 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..67e84ce51 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
@@ -182,9 +182,9 @@ var _ = Describe("GenreRepository", func() {
It("should filter by name using like match", func() {
// Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value")
options := rest.QueryOptions{
- Filters: map[string]any{"name": "%rock%"},
+ Filters: map[string]interface{}{"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{
- Filters: map[string]any{"library_id": 2},
+ genres, err := headlessRestRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]interface{}{"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..73815ae45 100644
--- a/persistence/helpers.go
+++ b/persistence/helpers.go
@@ -9,15 +9,13 @@ import (
"github.com/Masterminds/squirrel"
"github.com/fatih/structs"
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/db"
)
type PostMapper interface {
PostMapArgs(map[string]any) error
}
-func toSQLArgs(rec any) (map[string]any, error) {
+func toSQLArgs(rec interface{}) (map[string]interface{}, error) {
m := structs.Map(rec)
for k, v := range m {
switch t := v.(type) {
@@ -73,7 +71,7 @@ type existsCond struct {
not bool
}
-func (e existsCond) ToSql() (string, []any, error) {
+func (e existsCond) ToSql() (string, []interface{}, error) {
sql, args, err := e.cond.ToSql()
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
if e.not {
@@ -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..314b682bb 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,82 @@ 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
+ _, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;"))
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,55 +240,46 @@ 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()
delete(libCache, id)
- // 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)
- }
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 +287,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) (interface{}, 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) (interface{}, error) {
+ return r.GetAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *libraryRepository) EntityName() string {
+ return "library"
+}
+
+func (r *libraryRepository) NewInstance() interface{} {
+ return &model.Library{}
+}
+
+func (r *libraryRepository) Save(entity interface{}) (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 interface{}, 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..6f4df1beb 100644
--- a/persistence/library_repository_test.go
+++ b/persistence/library_repository_test.go
@@ -2,11 +2,7 @@ package persistence
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 +19,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 +36,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,21 +58,16 @@ 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
originalCreatedAt := lib.CreatedAt
- // Ensure the update's timestamp is strictly greater than the
- // create's timestamp on platforms with coarse clock resolution
- // (Windows' time.Now() is millisecond-granular).
- time.Sleep(2 * time.Millisecond)
-
// 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 +76,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 +92,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 +112,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))
@@ -211,140 +142,4 @@ var _ = Describe("LibraryRepository", func() {
Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum))
Expect(libAfter.TotalDuration).To(Equal(durationRes.Sum))
})
-
- Describe("ScanBegin and ScanEnd", func() {
- var lib *model.Library
-
- BeforeEach(func() {
- lib = &model.Library{
- ID: 0,
- Name: "Test Scan Library",
- Path: "/music/test-scan",
- }
- err := repo.Put(ctx, lib)
- Expect(err).ToNot(HaveOccurred())
- })
-
- DescribeTable("ScanBegin",
- func(fullScan bool, expectedFullScanInProgress bool) {
- err := repo.ScanBegin(ctx, lib.ID, fullScan)
- Expect(err).ToNot(HaveOccurred())
-
- updatedLib, err := repo.Get(ctx, lib.ID)
- Expect(err).ToNot(HaveOccurred())
- Expect(updatedLib.LastScanStartedAt).ToNot(BeZero())
- Expect(updatedLib.FullScanInProgress).To(Equal(expectedFullScanInProgress))
- },
- Entry("sets FullScanInProgress to true for full scan", true, true),
- Entry("sets FullScanInProgress to false for quick scan", false, false),
- )
-
- Context("ScanEnd", func() {
- BeforeEach(func() {
- err := repo.ScanBegin(ctx, lib.ID, true)
- Expect(err).ToNot(HaveOccurred())
- })
-
- It("sets LastScanAt and clears FullScanInProgress and LastScanStartedAt", func() {
- err := repo.ScanEnd(ctx, lib.ID)
- Expect(err).ToNot(HaveOccurred())
-
- updatedLib, err := repo.Get(ctx, lib.ID)
- Expect(err).ToNot(HaveOccurred())
- Expect(updatedLib.LastScanAt).ToNot(BeZero())
- Expect(updatedLib.FullScanInProgress).To(BeFalse())
- Expect(updatedLib.LastScanStartedAt).To(BeZero())
- })
-
- It("sets LastScanAt to be after LastScanStartedAt", func() {
- libBefore, err := repo.Get(ctx, lib.ID)
- Expect(err).ToNot(HaveOccurred())
-
- err = repo.ScanEnd(ctx, lib.ID)
- Expect(err).ToNot(HaveOccurred())
-
- libAfter, err := repo.Get(ctx, 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..e7883947a 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -3,22 +3,17 @@ package persistence
import (
"context"
"fmt"
- "iter"
- "maps"
"slices"
- "strconv"
- "strings"
"sync"
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "github.com/google/uuid"
"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 +33,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
@@ -69,11 +56,8 @@ func (m *dbMediaFile) PostScan() error {
func (m *dbMediaFile) PostMapArgs(args map[string]any) error {
fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist,
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle}
- participantNames := m.MediaFile.Participants.AllNames()
- fullText = append(fullText, participantNames...)
+ fullText = append(fullText, m.MediaFile.Participants.AllNames()...)
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["tags"] = marshalTags(m.MediaFile.Tags)
args["participants"] = marshalParticipants(m.MediaFile.Participants)
return nil
@@ -85,8 +69,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 +79,11 @@ 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
}
@@ -118,13 +92,11 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
filters := map[string]filterFunc{
"id": idFilter("media_file"),
"title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
- "starred": annotationBoolFilter("starred"),
- "has_rating": annotationBoolFilter("rating"),
- "genre_id": genreFilter(SongGenres),
+ "starred": booleanFilter,
+ "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,82 +107,44 @@ 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...).
- Columns("lower(suffix) as suffix", "count(*) as count").
- GroupBy("lower(suffix)")
- var res []struct {
- Suffix string
- Count int64
- }
- err := r.queryAll(ctx, sel, &res)
- if err != nil {
- return nil, err
- }
- counts := make(map[string]int64, len(res))
- for _, c := range res {
- counts[c.Suffix] = c.Count
- }
- return counts, nil
+func (r *mediaFileRepository) Exists(id string) (bool, error) {
+ return r.exists(Eq{"media_file.id": id})
}
-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) Put(ctx context.Context, 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})
+func (r *mediaFileRepository) Put(m *model.MediaFile) error {
+ m.CreatedAt = time.Now()
+ 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}))
- 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,231 +154,72 @@ 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
-}
-
-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) {
- placeholders := make([]string, len(values))
- args := make([]any, len(values))
- for i, v := range values {
- placeholders[i] = "?"
- args[i] = v
- }
- tagFilter := Expr(
- fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and value in (%s))",
- tag, strings.Join(placeholders, ",")),
- args...,
- )
-
- var opts model.QueryOptions
- if len(options) > 0 {
- opts = options[0]
- }
- if opts.Filters != nil {
- opts.Filters = And{tagFilter, opts.Filters}
- } else {
- opts.Filters = tagFilter
- }
- return r.GetAll(ctx, 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)
- 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
-
- 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)
- }
- }
- 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))
- var res dbMediaFiles
- if err := r.queryAll(ctx, 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) 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 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", m))
+ return
+ }
+ if !yield(*m.MediaFile, err) || err != nil {
+ return
+ }
+ }
+ }, nil
}
-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)
+func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
+ sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
+ var res dbMediaFiles
+ if err := r.queryAll(sel, &res); err != nil {
+ return nil, 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
+ return res.toModels(), nil
}
-func (r *mediaFileRepository) DeleteAllMissing(ctx context.Context) (int64, error) {
- user := loggedUser(ctx)
+func (r *mediaFileRepository) Delete(id string) error {
+ return r.delete(Eq{"id": id})
+}
+
+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 +227,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 +253,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 +266,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 +276,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,33 +284,32 @@ 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
}
- return wrapMediaFileCursor(cursor), nil
-}
-
-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 !yield(*m.MediaFile, err) || err != nil {
+ return
+ }
+ }
+ }, nil
}
// 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").
- LeftJoin("library on media_file.library_id = library.id").
- Where(And{
- NotEq{"media_file.library_id": missing.LibraryID},
- Eq{"media_file.mbz_release_track_id": missing.MbzReleaseTrackID},
- NotEq{"media_file.mbz_release_track_id": ""}, // Exclude empty MBZ Track IDs
- Eq{"media_file.suffix": missing.Suffix},
- Gt{"media_file.created_at": since},
- Eq{"media_file.missing": false},
- }).OrderBy("media_file.created_at DESC")
+func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
+ sel := r.selectMediaFile().Where(And{
+ NotEq{"media_file.library_id": missing.LibraryID},
+ Eq{"media_file.mbz_release_track_id": missing.MbzReleaseTrackID},
+ NotEq{"media_file.mbz_release_track_id": ""}, // Exclude empty MBZ Track IDs
+ Eq{"media_file.suffix": missing.Suffix},
+ Gt{"media_file.created_at": since},
+ Eq{"media_file.missing": false},
+ }).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
}
@@ -543,80 +317,63 @@ 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").
- LeftJoin("library on media_file.library_id = library.id").
- Where(And{
- NotEq{"media_file.library_id": missing.LibraryID},
- Eq{"media_file.title": missing.Title},
- Eq{"media_file.size": missing.Size},
- Eq{"media_file.suffix": missing.Suffix},
- Eq{"media_file.disc_number": missing.DiscNumber},
- Eq{"media_file.track_number": missing.TrackNumber},
- Eq{"media_file.album": missing.Album},
- Eq{"media_file.mbz_release_track_id": ""}, // Exclude files with MBZ Track ID
- Gt{"media_file.created_at": since},
- Eq{"media_file.missing": false},
- }).OrderBy("media_file.created_at DESC")
+func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
+ sel := r.selectMediaFile().Where(And{
+ NotEq{"media_file.library_id": missing.LibraryID},
+ Eq{"media_file.title": missing.Title},
+ Eq{"media_file.size": missing.Size},
+ Eq{"media_file.suffix": missing.Suffix},
+ Eq{"media_file.disc_number": missing.DiscNumber},
+ Eq{"media_file.track_number": missing.TrackNumber},
+ Eq{"media_file.album": missing.Album},
+ Eq{"media_file.mbz_release_track_id": ""}, // Exclude files with MBZ Track ID
+ Gt{"media_file.created_at": since},
+ Eq{"media_file.missing": false},
+ }).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
}
return res.toModels(), nil
}
-var mediaFileSearchConfig = searchConfig{
- NaturalOrder: "media_file.rowid",
- OrderBy: []string{"title"},
- 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) {
- var opts model.QueryOptions
- if len(options) > 0 {
- opts = options[0]
- }
+func (r *mediaFileRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) {
var res dbMediaFiles
- err := r.doSearch(ctx, r.selectMediaFile(ctx, options...), q, &res, mediaFileSearchConfig, opts)
- if err != nil {
- return nil, fmt.Errorf("searching media_file %q: %w", q, err)
+ if uuid.Validate(q) == nil {
+ err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, &res)
+ if err != nil {
+ return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err)
+ }
+ } else {
+ err := r.doSearch(r.selectMediaFile(options...), q, offset, size, &res, "media_file.rowid", "title")
+ if err != nil {
+ return nil, fmt.Errorf("searching media_file by query %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) (interface{}, 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) (interface{}, error) {
+ return r.GetAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *mediaFileRepository) EntityName() string {
+ return "mediafile"
+}
+
+func (r *mediaFileRepository) NewInstance() interface{} {
+ 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..ab926c00d 100644
--- a/persistence/mediafile_repository_test.go
+++ b/persistence/mediafile_repository_test.go
@@ -2,21 +2,15 @@ package persistence
import (
"context"
- "errors"
- "fmt"
- "reflect"
"time"
"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/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,166 +18,32 @@ 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)))
- })
- })
-
- Describe("CountBySuffix", func() {
- var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile
-
- BeforeEach(func() {
- mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "test/file.mp3"}
- flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "test/file1.flac"}
- 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())
- })
-
- AfterEach(func() {
- _ = mr.Delete(ctx, mp3File.ID)
- _ = mr.Delete(ctx, flacFile1.ID)
- _ = mr.Delete(ctx, flacFile2.ID)
- _ = mr.Delete(ctx, flacUpperFile.ID)
- })
-
- It("counts media files grouped by suffix with lowercase normalization", func() {
- counts, err := mr.CountBySuffix(ctx)
- Expect(err).ToNot(HaveOccurred())
-
- // Should have lowercase keys only
- Expect(counts).To(HaveKey("mp3"))
- Expect(counts).To(HaveKey("flac"))
- Expect(counts).ToNot(HaveKey("FLAC"))
-
- // mp3: 1 file
- Expect(counts["mp3"]).To(Equal(int64(1)))
- // flac: 3 files (2 lowercase + 1 uppercase normalized)
- Expect(counts["flac"]).To(Equal(int64(3)))
- })
+ Expect(mr.CountAll()).To(Equal(int64(10)))
})
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,306 +65,80 @@ 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())
-
- retrieved, err := mr.Get(ctx, newFile.ID)
- Expect(err).ToNot(HaveOccurred())
- Expect(retrieved.CreatedAt).To(BeTemporally(">", before))
-
- _ = mr.Delete(ctx, newFile.ID)
- })
-
- It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() {
- originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
- newFile := model.MediaFile{
- ID: id.NewRandom(),
- LibraryID: 1,
- Path: "test/created-at-preserved.mp3",
- CreatedAt: originalTime,
- }
- Expect(mr.Put(ctx, &newFile)).To(Succeed())
-
- retrieved, err := mr.Get(ctx, newFile.ID)
- Expect(err).ToNot(HaveOccurred())
- Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
-
- _ = mr.Delete(ctx, newFile.ID)
- })
-
- It("does not reset CreatedAt when updating an existing file", func() {
- originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
- fileID := id.NewRandom()
- newFile := model.MediaFile{
- ID: fileID,
- LibraryID: 1,
- Path: "test/created-at-update.mp3",
- Title: "Original Title",
- CreatedAt: originalTime,
- }
- Expect(mr.Put(ctx, &newFile)).To(Succeed())
-
- // Update the file with a new title but zero CreatedAt
- updatedFile := model.MediaFile{
- ID: fileID,
- LibraryID: 1,
- Path: "test/created-at-update.mp3",
- Title: "Updated Title",
- // CreatedAt is zero - should NOT overwrite the stored value
- }
- Expect(mr.Put(ctx, &updatedFile)).To(Succeed())
-
- retrieved, err := mr.Get(ctx, 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)
- })
- })
-
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()))
Expect(mf.PlayCount).To(Equal(int64(1)))
})
- Describe("AverageRating", func() {
- var raw *mediaFileRepository
-
- BeforeEach(func() {
- raw = mr.(*mediaFileRepository)
- })
-
- 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())
-
- mf, err := mr.Get(ctx, newID)
- Expect(err).ToNot(HaveOccurred())
- Expect(mf.AverageRating).To(Equal(0.0))
-
- _, _ = raw.executeSQL(ctx, 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())
-
- mf, err := mr.Get(ctx, 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}))
- })
-
- 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.SetRating(ctx, 3, newID)).To(Succeed())
-
- user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
- user2Repo := NewMediaFileRepository(GetDBXBuilder())
- Expect(user2Repo.SetRating(user2Ctx, 5, newID)).To(Succeed())
-
- mf, err := mr.Get(ctx, 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}))
- })
-
- 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.SetRating(ctx, 4, newID)).To(Succeed())
-
- user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
- user2Repo := NewMediaFileRepository(GetDBXBuilder())
- Expect(user2Repo.SetRating(user2Ctx, 0, newID)).To(Succeed())
-
- mf, err := mr.Get(ctx, 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}))
- })
- })
-
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 +146,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()))
@@ -538,25 +172,25 @@ var _ = Describe("MediaRepository", func() {
ID: id.NewRandom(),
LibraryID: 1,
Title: "Old Song",
- Path: "test/old.mp3",
+ Path: "/test/old.mp3",
},
{
ID: id.NewRandom(),
LibraryID: 1,
Title: "Middle Song",
- Path: "test/middle.mp3",
+ Path: "/test/middle.mp3",
},
{
ID: id.NewRandom(),
LibraryID: 1,
Title: "New Song",
- Path: "test/new.mp3",
+ Path: "/test/new.mp3",
},
}
// 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
@@ -569,7 +203,7 @@ var _ = Describe("MediaRepository", func() {
// Update "Old Song": created long ago, updated recently
_, err := db.Update("media_file",
- map[string]any{
+ map[string]interface{}{
"created_at": oldTime,
"updated_at": newTime,
},
@@ -578,7 +212,7 @@ var _ = Describe("MediaRepository", func() {
// Update "Middle Song": created and updated at the same middle time
_, err = db.Update("media_file",
- map[string]any{
+ map[string]interface{}{
"created_at": middleTime,
"updated_at": middleTime,
},
@@ -587,7 +221,7 @@ var _ = Describe("MediaRepository", func() {
// Update "New Song": created recently, updated long ago
_, err = db.Update("media_file",
- map[string]any{
+ map[string]interface{}{
"created_at": newTime,
"updated_at": oldTime,
},
@@ -598,7 +232,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 +242,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 +265,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 +286,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,113 +308,13 @@ 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()))
- })
-
- })
- })
-
- Context("Filters", func() {
- var mfWithoutAnnotation model.MediaFile
-
- 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())
- })
-
- AfterEach(func() {
- _ = mr.Delete(ctx, mfWithoutAnnotation.ID)
- })
-
- Describe("starred", func() {
- It("false includes items without annotations", func() {
- res, err := mr.ReadAll(ctx, rest.QueryOptions{
- Filters: map[string]any{"starred": "false"},
- })
- Expect(err).ToNot(HaveOccurred())
- files := res
-
- var found bool
- for _, f := range files {
- if f.ID == mfWithoutAnnotation.ID {
- found = true
- break
- }
- }
- Expect(found).To(BeTrue(), "MediaFile without annotation should be included in starred=false filter")
- })
-
- It("true excludes items without annotations", func() {
- res, err := mr.ReadAll(ctx, rest.QueryOptions{
- Filters: map[string]any{"starred": "true"},
- })
- Expect(err).ToNot(HaveOccurred())
- files := res
-
- 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", 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
for _, result := range results {
@@ -785,7 +323,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", 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
for _, result := range results {
@@ -794,7 +332,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", 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@@ -813,21 +351,21 @@ var _ = Describe("MediaRepository", func() {
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4
MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4
LibraryID: 1,
- Path: "test/path/test.mp3",
+ Path: "/test/path/test.mp3",
}
// 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", 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
@@ -835,7 +373,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", 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("test-mbid-mediafile"))
@@ -843,7 +381,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", 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
@@ -855,508 +393,21 @@ var _ = Describe("MediaRepository", func() {
Title: "Test Missing MBID MediaFile",
MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022",
LibraryID: 1,
- Path: "test/path/missing.mp3",
+ Path: "/test/path/missing.mp3",
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", 0, 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() {
- // Test fixtures for Unicode and case-sensitivity tests
- var testFiles []model.MediaFile
-
- BeforeEach(func() {
- testFiles = []model.MediaFile{
- {ID: "findpath-1", LibraryID: 1, Path: "artist/Album/track.mp3", Title: "Track"},
- {ID: "findpath-2", LibraryID: 1, Path: "artist/Album/UPPER.mp3", Title: "Upper"},
- // Fullwidth uppercase: ACROSS (U+FF21 U+FF23 U+FF32 U+FF2F U+FF33 U+FF33)
- {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())
- }
- })
-
- AfterEach(func() {
- for _, mf := range testFiles {
- _ = mr.Delete(ctx, 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"})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(1))
- Expect(results[0].ID).To(Equal("findpath-1"))
- })
-
- 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"})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(1))
- Expect(results[0].ID).To(Equal("findpath-1"))
- })
-
- 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"})
- 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"})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(BeEmpty())
- })
-
- It("returns multiple files when querying multiple paths", func() {
- results, err := mr.FindByPaths(ctx, []string{
- "1:artist/Album/track.mp3",
- "1:artist/Album/UPPER.mp3",
- })
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(2))
- })
-
- It("returns empty slice for non-existent paths", func() {
- results, err := mr.FindByPaths(ctx, []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{})
- 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"})
- 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"})
- 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() {
- It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() {
- // Simulate what queryWithStableResults does on the rows.Err() path:
- // it yields a zero-value dbMediaFile (where MediaFile is nil) with an error.
- dbErr := fmt.Errorf("database is locked")
- cursor := func(yield func(dbMediaFile, error) bool) {
- var empty dbMediaFile // MediaFile pointer is nil
- yield(empty, dbErr)
- }
-
- // wrapMediaFileCursor should handle the nil MediaFile without panicking
- wrappedCursor := wrapMediaFileCursor(cursor)
- var gotErr error
- Expect(func() {
- for _, err := range wrappedCursor {
- gotErr = err
- }
- }).ToNot(Panic())
- Expect(gotErr).To(HaveOccurred())
- Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.MediaFile"))
- Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
- })
-
- It("yields mediafiles from a valid cursor", func() {
- mf := &model.MediaFile{ID: "mf1", Title: "Test"}
- cursor := func(yield func(dbMediaFile, error) bool) {
- yield(dbMediaFile{MediaFile: mf}, nil)
- }
-
- wrappedCursor := wrapMediaFileCursor(cursor)
- var mediafiles []model.MediaFile
- for m, err := range wrappedCursor {
- Expect(err).ToNot(HaveOccurred())
- mediafiles = append(mediafiles, m)
- }
- Expect(mediafiles).To(HaveLen(1))
- 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..ac607f85f 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,115 @@ 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) Plugin() model.PluginRepository {
- return s.plugin()
-}
-
-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 interface{}) 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)
}
- 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 +132,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,61 +148,16 @@ 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 {
+func (s *SQLStore) GC(ctx context.Context) error {
trace := func(ctx context.Context, msg string, f func() error) func() error {
return func() error {
start := time.Now()
@@ -253,27 +167,27 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
}
}
- // If libraryIDs are provided, scope operations to those libraries where possible
- scoped := len(libraryIDs) > 0
- if scoped {
- log.Debug(ctx, "GC: Running selective garbage collection", "libraryIDs", libraryIDs)
- }
-
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() }),
+ 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() }),
+ 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..f3cb4f3d0 100644
--- a/persistence/persistence_suite_test.go
+++ b/persistence/persistence_suite_test.go
@@ -4,9 +4,7 @@ import (
"context"
"path/filepath"
"testing"
- "time"
- "github.com/Masterminds/squirrel"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
@@ -14,6 +12,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
+ "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
@@ -57,53 +56,36 @@ func al(al model.Album) model.Album {
return al
}
-func alWithTags(a model.Album, tags model.Tags) model.Album {
- a = al(a)
- a.Tags = tags
- return a
-}
-
var (
- artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
- artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
- artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"}
- artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"}
- testArtists = model.Artists{
+ artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
+ artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
+ testArtists = model.Artists{
artistKraftwerk,
artistBeatles,
- artistCJK,
- artistPunctuation,
}
)
var (
- albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
- albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
- albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("kraft/radio/radio.mp3"), SongCount: 2})
- albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("test/multi/disc1/track1.mp3"), SongCount: 4})
- albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
- albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
- model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
- albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1})
- testAlbums = model.Albums{
+ albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
+ albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
+ albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
+ albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
+ testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
albumRadioactivity,
albumMultiDisc,
- albumCJK,
- albumWithVersion,
- albumPunctuation,
}
)
var (
- songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("beatles/1/sgt/a day.mp3")})
- songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("beatles/1/come together.mp3")})
- songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("kraft/radio/radio.mp3")})
+ songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("/beatles/1/sgt/a day.mp3")})
+ songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("/beatles/1/come together.mp3")})
+ songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("/kraft/radio/radio.mp3")})
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),
+ Path: p("/kraft/radio/antenna.mp3"),
+ RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0),
})
songAntennaWithLyrics = mf(model.MediaFile{
ID: "1005",
@@ -115,13 +97,10 @@ var (
})
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
// Multi-disc album tracks (intentionally out of order to test sorting)
- songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
- songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
- songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
- songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
- songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("seatbelts/cowboy-bebop/track1.mp3")})
- songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("beatles/2/come together.mp3")})
- songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("roots/things/track1.mp3")})
+ songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
@@ -133,9 +112,6 @@ var (
songDisc1Track01,
songDisc2Track01,
songDisc1Track02,
- songCJK,
- songVersioned,
- songPunctuation,
}
)
@@ -154,51 +130,23 @@ var (
var (
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
- thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
- 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}
+ testUsers = model.Users{adminUser, regularUser}
)
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,69 +154,51 @@ 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)
}
}
- // Populate album_artists based on the AlbumArtistID relationships in testAlbums
- artistIDs := map[string]bool{}
- for _, a := range testArtists {
- artistIDs[a.ID] = true
- }
- for i := range testAlbums {
- a := testAlbums[i]
- if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] {
- continue
- }
- _, err := alr.executeSQL(ctx, squirrel.Insert("album_artists").SetMap(map[string]any{
- "album_id": a.ID,
- "artist_id": a.AlbumArtistID,
- "role": "artist",
- "sub_role": "",
- }))
- if err != nil {
- panic(err)
- }
- }
-
- 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 +217,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 +240,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 +254,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..73c820753 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) (interface{}, 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) (interface{}, 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() interface{} {
+ 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 interface{}) (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 interface{}, 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..046284e1f 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -11,9 +11,10 @@ import (
. "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/utils/slice"
+ "github.com/navidrome/navidrome/model/criteria"
"github.com/pocketbase/dbx"
)
@@ -40,48 +41,42 @@ 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
}
-func playlistFilter(_ string, value any) Sqlizer {
+func playlistFilter(_ string, value interface{}) Sqlizer {
return Or{
substringFilter("playlist.name", value),
substringFilter("playlist.comment", value),
}
}
-func smartPlaylistFilter(string, any) Sqlizer {
+func smartPlaylistFilter(string, interface{}) Sqlizer {
return Or{
Eq{"rules": ""},
Eq{"rules": nil},
}
}
-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 +86,45 @@ 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) Put(ctx context.Context, 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")
+func (r *playlistRepository) Delete(id string) error {
+ usr := loggedUser(r.ctx)
+ if !usr.IsAdmin {
+ pls, err := r.Get(id)
+ if err != nil {
+ return err
+ }
+ if pls.OwnerID != usr.ID {
+ return rest.ErrPermissionDenied
}
- _, err := r.put(ctx, pls.ID, pls, cols...)
- return err
}
- isNew := pls.ID == ""
- if isNew {
+ return r.delete(And{Eq{"id": id}, r.userFilter()})
+}
+
+func (r *playlistRepository) Put(p *model.Playlist) error {
+ pls := dbPlaylist{Playlist: *p}
+ if pls.ID == "" {
pls.CreatedAt = time.Now()
+ } else {
+ ok, err := r.Exists(pls.ID)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return model.ErrNotAuthorized
+ }
}
pls.UpdatedAt = time.Now()
- id, err := r.put(ctx, pls.ID, pls)
+ id, err := r.put(pls.ID, pls)
if err != nil {
return err
}
@@ -130,52 +132,47 @@ func (r *playlistRepository) Put(ctx context.Context, p *model.Playlist, cols ..
if p.IsSmartPlaylist() {
// Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process
+ //r.refreshSmartPlaylist(p)
return nil
}
// 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 +180,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 +194,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 +213,120 @@ 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) refreshSmartPlaylist(pls *model.Playlist) bool {
+ // Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config
+ if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) {
+ return false
+ }
+
+ // Never refresh other users' playlists
+ usr := loggedUser(r.ctx)
+ if pls.OwnerID != usr.ID {
+ log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
+ return false
+ }
+
+ log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
+ start := time.Now()
+
+ // Remove old tracks
+ del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})
+ _, err := r.executeSQL(del)
+ if err != nil {
+ log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
+ return false
+ }
+
+ // Re-populate playlist based on Smart Playlist criteria
+ rules := *pls.Rules
+
+ // If the playlist depends on other playlists, recursively refresh them first
+ childPlaylistIds := rules.ChildPlaylistIds()
+ for _, id := range childPlaylistIds {
+ childPls, err := r.Get(id)
+ if err != nil {
+ log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err)
+ return false
+ }
+ r.refreshSmartPlaylist(childPls)
+ }
+
+ sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
+ From("media_file").LeftJoin("annotation on (" +
+ "annotation.item_id = media_file.id" +
+ " AND annotation.item_type = 'media_file'" +
+ " AND annotation.user_id = '" + usr.ID + "')")
+ sq = r.addCriteria(sq, rules)
+ insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
+ _, err = r.executeSQL(insSql)
+ if err != nil {
+ log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
+ return false
+ }
+
+ // Update playlist stats
+ err = r.refreshCounters(pls)
+ if err != nil {
+ log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err)
+ return false
+ }
+
+ // Update when the playlist was last refreshed (for cache purposes)
+ updSql := Update(r.tableName).Set("evaluated_at", time.Now()).Where(Eq{"id": pls.ID})
+ _, err = r.executeSQL(updSql)
+ if err != nil {
+ log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
+ return false
+ }
+
+ log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
+
+ return true
+}
+
+func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder {
+ sql = sql.Where(c)
+ if c.Limit > 0 {
+ sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset))
+ }
+ if order := c.OrderBy(); order != "" {
+ sql = sql.OrderBy(order)
+ }
+ return sql
+}
+
+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 {
+ if !r.isWritable(playlistId) {
+ return rest.ErrPermissionDenied
+ }
+
// 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 +336,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,52 +356,38 @@ 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",
"coalesce(play_count, 0) as play_count",
"play_date",
"coalesce(rating, 0) as rating",
- "rated_at",
"f.*",
"playlist_tracks.*",
"library.path as library_path",
@@ -397,49 +400,72 @@ 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) (interface{}, 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) (interface{}, error) {
+ return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
-func (r *playlistRepository) Save(ctx context.Context, pls *model.Playlist) (string, error) {
- pls.ID = "" // Force new creation
- err := r.Put(ctx, pls)
+func (r *playlistRepository) EntityName() string {
+ return "playlist"
+}
+
+func (r *playlistRepository) NewInstance() interface{} {
+ return &model.Playlist{}
+}
+
+func (r *playlistRepository) Save(entity interface{}) (string, error) {
+ pls := entity.(*model.Playlist)
+ pls.OwnerID = loggedUser(r.ctx).ID
+ pls.ID = "" // Make sure we don't override an existing playlist
+ 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 interface{}, cols ...string) error {
+ pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
+ current, err := r.Get(id)
+ if err != nil {
+ return err
+ }
+ usr := loggedUser(r.ctx)
+ if !usr.IsAdmin {
+ // Only the owner can update the playlist
+ if current.OwnerID != usr.ID {
+ return rest.ErrPermissionDenied
+ }
+ // Regular users can't change the ownership of a playlist
+ if pls.OwnerID != "" && pls.OwnerID != usr.ID {
+ return rest.ErrPermissionDenied
+ }
+ }
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,59 +473,50 @@ 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)
}
}
return nil
}
-// 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 {
- // Step 1: Negate all IDs to clear the positive ID space
- _, err := r.executeSQL(ctx, Expr(
- `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id))
+func (r *playlistRepository) renumber(id string) error {
+ var ids []string
+ sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id")
+ err := r.queryAllSlice(sq, &ids)
if err != nil {
return err
}
- // 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(
- `WITH new_ids AS (
- SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id
- FROM playlist_tracks WHERE playlist_id = ?
- )
- UPDATE playlist_tracks SET id = new_ids.new_id
- FROM new_ids
- WHERE playlist_tracks.rowid = new_ids.rid AND playlist_tracks.playlist_id = ?`, id, id))
- if err != nil {
- return err
+ return r.updatePlaylist(id, ids)
+}
+
+func (r *playlistRepository) isWritable(playlistId string) bool {
+ usr := loggedUser(r.ctx)
+ if usr.IsAdmin {
+ return true
}
- r.enqueueCoverRebuild(ctx, id)
- return r.refreshCounters(ctx, &model.Playlist{ID: id})
+ pls, err := r.Get(playlistId)
+ return err == nil && pls.OwnerID == usr.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..7fad93b1e 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -2,102 +2,44 @@ package persistence
import (
"context"
- "slices"
+ "time"
- "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(context.TODO())
+ 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 +53,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 +76,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,112 +114,142 @@ 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))
})
})
- Describe("Track Deletion and Renumbering", func() {
+ Context("Smart Playlists", func() {
+ var rules *criteria.Criteria
+ BeforeEach(func() {
+ rules = &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.Contains{"title": "love"},
+ },
+ }
+ })
+ Context("valid rules", func() {
+ Specify("Put/Get", func() {
+ newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(Succeed())
+
+ savedPls, err := repo.Get(newPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(savedPls.Rules).To(Equal(rules))
+ })
+ })
+
+ Context("invalid rules", func() {
+ It("fails to Put it in the DB", func() {
+ rules = &criteria.Criteria{
+ // This is invalid because "contains" cannot have multiple fields
+ Expression: criteria.All{
+ criteria.Contains{"genre": "Hardcore", "filetype": "mp3"},
+ },
+ }
+ newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression")))
+ })
+ })
+
+ // TODO Validate these tests
+ XContext("child smart playlists", func() {
+ When("refresh day has expired", func() {
+ It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+
+ nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&nestedPls)).To(Succeed())
+
+ parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.InPlaylist{"id": nestedPls.ID},
+ },
+ }}
+ Expect(repo.Put(&parentPls)).To(Succeed())
+
+ nestedPlsRead, err := repo.Get(nestedPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = repo.GetWithTracks(parentPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get
+ nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt))
+ })
+ })
+
+ When("refresh day 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
+
+ nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
+ Expect(repo.Put(&nestedPls)).To(Succeed())
+
+ parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
+ Expression: criteria.All{
+ criteria.InPlaylist{"id": nestedPls.ID},
+ },
+ }}
+ Expect(repo.Put(&parentPls)).To(Succeed())
+
+ nestedPlsRead, err := repo.Get(nestedPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ _, err = repo.GetWithTracks(parentPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get
+ nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
+ })
+ })
+ })
+ })
+
+ Describe("Playlist Track Sorting", func() {
var testPlaylistID string
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)
+ It("sorts tracks correctly by album (disc and track number)", func() {
+ By("creating a playlist with multi-disc album tracks in arbitrary order")
+ 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(&newPls)).To(Succeed())
+ testPlaylistID = newPls.ID
+
+ By("retrieving tracks sorted by album")
+ tracksRepo := repo.Tracks(newPls.ID, false)
+ tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
Expect(err).ToNot(HaveOccurred())
- for _, t := range pls.Tracks {
- ids = append(ids, t.ID)
- mediaFileIDs = append(mediaFileIDs, t.MediaFileID)
- }
- return
- }
- It("renumbers correctly after deleting a track from the middle", 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())
- testPlaylistID = newPls.ID
-
- By("deleting the second track (position 2)")
- tracksRepo := repo.Tracks(ctx, newPls.ID, false)
- Expect(tracksRepo.Delete(ctx, "2")).To(Succeed())
-
- By("verifying remaining tracks are renumbered sequentially")
- ids, mediaFileIDs := getTrackInfo(newPls.ID)
- Expect(ids).To(Equal([]string{"1", "2", "3"}))
- Expect(mediaFileIDs).To(Equal([]string{"1001", "1003", "1004"}))
- })
-
- It("renumbers correctly after deleting the first track", 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())
- testPlaylistID = newPls.ID
-
- By("deleting the first track (position 1)")
- tracksRepo := repo.Tracks(ctx, newPls.ID, false)
- Expect(tracksRepo.Delete(ctx, "1")).To(Succeed())
-
- By("verifying remaining tracks are renumbered sequentially")
- ids, mediaFileIDs := getTrackInfo(newPls.ID)
- Expect(ids).To(Equal([]string{"1", "2"}))
- Expect(mediaFileIDs).To(Equal([]string{"1002", "1003"}))
- })
-
- It("renumbers correctly after deleting the last track", 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())
- testPlaylistID = newPls.ID
-
- By("deleting the last track (position 3)")
- tracksRepo := repo.Tracks(ctx, newPls.ID, false)
- Expect(tracksRepo.Delete(ctx, "3")).To(Succeed())
-
- By("verifying remaining tracks are renumbered sequentially")
- ids, mediaFileIDs := getTrackInfo(newPls.ID)
- Expect(ids).To(Equal([]string{"1", "2"}))
- 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")
+ By("verifying tracks are sorted by disc number then track number")
+ Expect(tracks).To(HaveLen(4))
+ // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
+ Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
+ Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
+ Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
+ Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
})
})
})
diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go
index 392446cef..b3f9e0c07 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) (interface{}, error) {
+ userID := loggedUser(r.ctx).ID
+ sel := r.newSelect().
LeftJoin("annotation on ("+
"annotation.item_id = media_file_id"+
" AND annotation.item_type = 'media_file'"+
@@ -108,138 +97,94 @@ func (r *playlistTrackRepository) Read(ctx context.Context, id string) (*model.P
"coalesce(rating, 0) as rating",
"starred_at",
"play_date",
- "rated_at",
"f.*",
"playlist_tracks.*",
).
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) (interface{}, 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() interface{} {
+ return &model.PlaylistTrack{}
+}
+
+func (r *playlistTrackRepository) isTracksEditable() bool {
+ return r.playlistRepo.isWritable(r.playlistId) && !r.playlist.IsSmartPlaylist()
+}
+
+func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
+ if !r.isTracksEditable() {
+ return 0, rest.ErrPermissionDenied
+ }
+
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,80 +192,55 @@ 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
- }
+// Get ids from all current tracks
+func (r *playlistTrackRepository) getTracks() ([]string, error) {
+ all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
+ var ids []string
+ err := r.queryAllSlice(all, &ids)
+ if err != nil {
+ log.Error(r.ctx, "Error querying current tracks from playlist", "playlistId", r.playlistId, err)
+ return nil, err
}
-
- return r.playlistRepo.renumber(ctx, r.playlistId)
+ return ids, nil
}
-func (r *playlistTrackRepository) DeleteAll(ctx context.Context) error {
- err := r.delete(ctx, Eq{"playlist_id": r.playlistId})
+func (r *playlistTrackRepository) Delete(ids ...string) error {
+ if !r.isTracksEditable() {
+ return rest.ErrPermissionDenied
+ }
+ 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 {
- return err
+func (r *playlistTrackRepository) DeleteAll() error {
+ if !r.isTracksEditable() {
+ return rest.ErrPermissionDenied
}
- last := int(res.Max.Int32)
- if pos < 1 || pos > last {
- return model.ErrNotFound
- }
- newPos = min(max(newPos, 1), last)
- 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(
- `UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos))
+ err := r.delete(Eq{"playlist_id": r.playlistId})
if err != nil {
return err
}
- // Step 2: Shift the affected range using negative values to avoid unique constraint violations
- if pos < newPos {
- _, err = r.executeSQL(ctx, Expr(
- `UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`,
- pid, pos, newPos))
- } else {
- _, err = r.executeSQL(ctx, Expr(
- `UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`,
- pid, newPos, pos))
+ return r.playlistRepo.renumber(r.playlistId)
+}
+
+func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
+ if !r.isTracksEditable() {
+ return rest.ErrPermissionDenied
}
+ ids, err := r.getTracks()
if err != nil {
return err
}
-
- // Step 3: Flip the shifted range back to positive
- _, err = r.executeSQL(ctx, 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(
- `UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid))
- return err
+ newOrder := slice.Move(ids, pos-1, newPos-1)
+ return r.playlistRepo.updatePlaylist(r.playlistId, newOrder)
}
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)
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..74c80ee92 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 {
@@ -120,8 +122,8 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
UpdatedAt: pq.UpdatedAt,
}
if strings.TrimSpace(pq.Items) != "" {
- tracks := strings.SplitSeq(pq.Items, ",")
- for t := range tracks {
+ tracks := strings.Split(pq.Items, ",")
+ for _, t := range tracks {
q.Items = append(q.Items, model.MediaFile{ID: t})
}
}
@@ -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.go b/persistence/plugin_cleanup.go
deleted file mode 100644
index 0202726e4..000000000
--- a/persistence/plugin_cleanup.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package persistence
-
-import (
- "github.com/pocketbase/dbx"
-)
-
-// cleanupPluginUserReferences removes a user ID from all plugins' users JSON arrays
-// and auto-disables plugins that lose their only permitted user (when users permission is required).
-// This is called from userRepository.Delete() to maintain referential integrity.
-func cleanupPluginUserReferences(db dbx.Builder, userID string) error {
- // SQLite JSON function: json_remove removes the element at the path where user matches.
- // We use a subquery with json_each to find and remove the user ID from the array.
- // This updates all plugins where the users array contains the given user ID.
- _, err := db.NewQuery(`
- UPDATE plugin
- SET users = (
- SELECT json_group_array(value)
- FROM json_each(plugin.users)
- WHERE value != {:userID}
- ),
- updated_at = CURRENT_TIMESTAMP
- WHERE users IS NOT NULL
- AND users != ''
- AND EXISTS (SELECT 1 FROM json_each(plugin.users) WHERE value = {:userID})
- `).Bind(dbx.Params{"userID": userID}).Execute()
- if err != nil {
- return err
- }
-
- // Auto-disable plugins that:
- // 1. Are currently enabled
- // 2. Require users permission (manifest has permissions.users)
- // 3. Don't have allUsers enabled
- // 4. Now have an empty users array after cleanup
- //
- // The manifest check uses JSON path to see if permissions.users exists.
- _, err = db.NewQuery(`
- UPDATE plugin
- SET enabled = false,
- updated_at = CURRENT_TIMESTAMP
- WHERE enabled = true
- AND all_users = false
- AND json_extract(manifest, '$.permissions.users') IS NOT NULL
- AND (users IS NULL OR users = '' OR users = '[]' OR json_array_length(users) = 0)
- `).Execute()
- return err
-}
-
-// cleanupPluginLibraryReferences removes a library ID from all plugins' libraries JSON arrays
-// and auto-disables plugins that lose their only permitted library (when library permission is required).
-// This is called from libraryRepository.Delete() to maintain referential integrity.
-func cleanupPluginLibraryReferences(db dbx.Builder, libraryID int) error {
- // SQLite JSON function: we filter out the library ID from the array.
- // Libraries are stored as integers in the JSON array.
- _, err := db.NewQuery(`
- UPDATE plugin
- SET libraries = (
- SELECT json_group_array(value)
- FROM json_each(plugin.libraries)
- WHERE CAST(value AS INTEGER) != {:libraryID}
- ),
- updated_at = CURRENT_TIMESTAMP
- WHERE libraries IS NOT NULL
- AND libraries != ''
- AND EXISTS (SELECT 1 FROM json_each(plugin.libraries) WHERE CAST(value AS INTEGER) = {:libraryID})
- `).Bind(dbx.Params{"libraryID": libraryID}).Execute()
- if err != nil {
- return err
- }
-
- // Auto-disable plugins that:
- // 1. Are currently enabled
- // 2. Require library permission (manifest has permissions.library)
- // 3. Don't have allLibraries enabled
- // 4. Now have an empty libraries array after cleanup
- _, err = db.NewQuery(`
- UPDATE plugin
- SET enabled = false,
- updated_at = CURRENT_TIMESTAMP
- WHERE enabled = true
- AND all_libraries = false
- AND json_extract(manifest, '$.permissions.library') IS NOT NULL
- AND (libraries IS NULL OR libraries = '' OR libraries = '[]' OR json_array_length(libraries) = 0)
- `).Execute()
- return err
-}
diff --git a/persistence/plugin_cleanup_test.go b/persistence/plugin_cleanup_test.go
deleted file mode 100644
index 08959075d..000000000
--- a/persistence/plugin_cleanup_test.go
+++ /dev/null
@@ -1,282 +0,0 @@
-package persistence
-
-import (
- "context"
-
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-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})
- db := GetDBXBuilder()
- pluginRepo = NewPluginRepository(db)
- userRepo = NewUserRepository(db)
- libraryRepo = NewLibraryRepository(db)
-
- // Clean up any existing plugins
- all, _ := pluginRepo.GetAll(ctx)
- for _, p := range all {
- _ = pluginRepo.Delete(ctx, p.ID)
- }
- })
-
- AfterEach(func() {
- // Clean up after tests
- all, _ := pluginRepo.GetAll(ctx)
- for _, p := range all {
- _ = pluginRepo.Delete(ctx, 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
- plugin := &model.Plugin{
- ID: "test-plugin",
- Path: "/plugins/test.wasm",
- Manifest: `{"name":"test"}`,
- SHA256: "abc123",
- Users: `["user1","user2","user3"]`,
- Enabled: true,
- }
- Expect(pluginRepo.Put(ctx, 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")
- Expect(err).ToNot(HaveOccurred())
- Expect(updated.Users).To(Equal(`["user1","user3"]`))
- Expect(updated.Enabled).To(BeTrue()) // Still has users, should remain enabled
- })
-
- It("auto-disables plugin when last permitted user is removed", func() {
- // Create a plugin that requires users permission with only one user
- plugin := &model.Plugin{
- ID: "user-plugin",
- Path: "/plugins/user.wasm",
- Manifest: `{"name":"user-plugin","permissions":{"users":{}}}`,
- SHA256: "def456",
- Users: `["only-user"]`,
- AllUsers: false,
- Enabled: true,
- }
- Expect(pluginRepo.Put(ctx, 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")
- Expect(err).ToNot(HaveOccurred())
- Expect(updated.Users).To(Equal(`[]`))
- Expect(updated.Enabled).To(BeFalse())
- })
-
- It("does not disable plugin when allUsers is true", func() {
- plugin := &model.Plugin{
- ID: "all-users-plugin",
- Path: "/plugins/all.wasm",
- Manifest: `{"name":"all-users","permissions":{"users":{}}}`,
- SHA256: "ghi789",
- Users: `["user1"]`,
- AllUsers: true,
- Enabled: true,
- }
- Expect(pluginRepo.Put(ctx, 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")
- Expect(err).ToNot(HaveOccurred())
- Expect(updated.Enabled).To(BeTrue())
- })
-
- It("does not affect plugins without users permission requirement", func() {
- plugin := &model.Plugin{
- ID: "no-users-perm",
- Path: "/plugins/noperm.wasm",
- Manifest: `{"name":"no-perm"}`, // No permissions.users in manifest
- SHA256: "jkl012",
- Users: `["user1"]`,
- Enabled: true,
- }
- Expect(pluginRepo.Put(ctx, 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")
- Expect(err).ToNot(HaveOccurred())
- Expect(updated.Users).To(Equal(`[]`))
- Expect(updated.Enabled).To(BeTrue())
- })
- })
-
- Describe("cleanupPluginLibraryReferences", func() {
- It("removes library ID from plugin libraries array", func() {
- // Create a plugin with multiple libraries
- plugin := &model.Plugin{
- ID: "lib-plugin",
- Path: "/plugins/lib.wasm",
- Manifest: `{"name":"lib-plugin"}`,
- SHA256: "mno345",
- Libraries: `[1,2,3]`,
- Enabled: true,
- }
- Expect(pluginRepo.Put(ctx, 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")
- Expect(err).ToNot(HaveOccurred())
- Expect(updated.Libraries).To(Equal(`[1,3]`))
- })
-
- It("auto-disables plugin when last permitted library is removed", func() {
- // Create a plugin that requires library permission with only one library
- plugin := &model.Plugin{
- ID: "lib-only-plugin",
- Path: "/plugins/libonly.wasm",
- Manifest: `{"name":"lib-only","permissions":{"library":{}}}`,
- SHA256: "pqr678",
- Libraries: `[99]`,
- AllLibraries: false,
- Enabled: true,
- }
- Expect(pluginRepo.Put(ctx, 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")
- Expect(err).ToNot(HaveOccurred())
- Expect(updated.Libraries).To(Equal(`[]`))
- Expect(updated.Enabled).To(BeFalse())
- })
-
- It("does not disable plugin when allLibraries is true", func() {
- plugin := &model.Plugin{
- ID: "all-libs-plugin",
- Path: "/plugins/alllibs.wasm",
- Manifest: `{"name":"all-libs","permissions":{"library":{}}}`,
- SHA256: "stu901",
- Libraries: `[1]`,
- AllLibraries: true,
- Enabled: true,
- }
- Expect(pluginRepo.Put(ctx, 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")
- Expect(err).ToNot(HaveOccurred())
- Expect(updated.Enabled).To(BeTrue())
- })
- })
-
- Describe("User Delete integration", func() {
- It("cleans up plugin references when user is deleted", func() {
- // Create a test user
- user := &model.User{
- ID: "test-delete-user",
- UserName: "plugin-cleanup-test-user",
- IsAdmin: false,
- }
- user.NewPassword = "password123"
- Expect(userRepo.Put(ctx, user)).To(Succeed())
-
- // Create a plugin referencing this user
- plugin := &model.Plugin{
- ID: "user-ref-plugin",
- Path: "/plugins/userref.wasm",
- Manifest: `{"name":"user-ref"}`,
- SHA256: "xyz123",
- Users: `["test-delete-user","other-user"]`,
- Enabled: true,
- }
- Expect(pluginRepo.Put(ctx, plugin)).To(Succeed())
-
- // Delete the user
- Expect(userRepo.Delete(ctx, "test-delete-user")).To(Succeed())
-
- // Verify user was removed from plugin
- updated, err := pluginRepo.Get(ctx, "user-ref-plugin")
- Expect(err).ToNot(HaveOccurred())
- Expect(updated.Users).To(Equal(`["other-user"]`))
- })
- })
-
- Describe("Library Delete integration", func() {
- It("cleans up plugin references when library is deleted", func() {
- // Create a test library (ID > 1 since ID 1 cannot be deleted)
- library := &model.Library{
- ID: 99,
- Name: "Test Library",
- Path: "/tmp/test-lib",
- }
- Expect(libraryRepo.Put(ctx, library)).To(Succeed())
-
- // Create a plugin referencing this library
- plugin := &model.Plugin{
- ID: "lib-ref-plugin",
- Path: "/plugins/libref.wasm",
- Manifest: `{"name":"lib-ref"}`,
- SHA256: "abc789",
- Libraries: `[99,1]`,
- Enabled: true,
- }
- Expect(pluginRepo.Put(ctx, plugin)).To(Succeed())
-
- // Delete the library
- Expect(libraryRepo.Delete(ctx, 99)).To(Succeed())
-
- // Verify library was removed from plugin
- updated, err := pluginRepo.Get(ctx, "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
deleted file mode 100644
index 1545ce2ba..000000000
--- a/persistence/plugin_repository.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package persistence
-
-import (
- "context"
- "errors"
- "time"
-
- . "github.com/Masterminds/squirrel"
- "github.com/deluan/rest"
- "github.com/navidrome/navidrome/model"
- "github.com/pocketbase/dbx"
-)
-
-type pluginRepository struct {
- sqlRepository
-}
-
-func NewPluginRepository(db dbx.Builder) model.PluginRepository {
- r := &pluginRepository{}
- r.db = db
- r.registerModel(&model.Plugin{}, map[string]filterFunc{
- "id": idFilter("plugin"),
- "enabled": booleanFilter,
- })
- return r
-}
-
-func (r *pluginRepository) isPermitted(ctx context.Context) bool {
- user := loggedUser(ctx)
- return user.IsAdmin
-}
-
-func (r *pluginRepository) ClearErrors(ctx context.Context) error {
- if !r.isPermitted(ctx) {
- 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) {
- return 0, rest.ErrPermissionDenied
- }
- sql := r.newSelect(ctx)
- return r.count(ctx, sql, options...)
-}
-
-func (r *pluginRepository) Delete(ctx context.Context, id string) error {
- if !r.isPermitted(ctx) {
- return rest.ErrPermissionDenied
- }
- return r.delete(ctx, Eq{"id": id})
-}
-
-func (r *pluginRepository) Get(ctx context.Context, id string) (*model.Plugin, error) {
- if !r.isPermitted(ctx) {
- return nil, rest.ErrPermissionDenied
- }
- sel := r.newSelect(ctx).Where(Eq{"id": id}).Columns("*")
- res := model.Plugin{}
- err := r.queryOne(ctx, sel, &res)
- return &res, err
-}
-
-func (r *pluginRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Plugins, error) {
- if !r.isPermitted(ctx) {
- return nil, rest.ErrPermissionDenied
- }
- sel := r.newSelect(ctx, options...).Columns("*")
- res := model.Plugins{}
- err := r.queryAll(ctx, sel, &res)
- return res, err
-}
-
-func (r *pluginRepository) Put(ctx context.Context, plugin *model.Plugin) error {
- if !r.isPermitted(ctx) {
- return rest.ErrPermissionDenied
- }
-
- plugin.UpdatedAt = time.Now()
-
- if plugin.ID == "" {
- return errors.New("plugin ID cannot be empty")
- }
-
- // Upsert using INSERT ... ON CONFLICT for atomic operation
- _, err := r.db.NewQuery(`
- INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, allow_write_access, enabled, last_error, sha256, created_at, updated_at)
- VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:allow_write_access}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
- ON CONFLICT(id) DO UPDATE SET
- path = excluded.path,
- manifest = excluded.manifest,
- config = excluded.config,
- users = excluded.users,
- all_users = excluded.all_users,
- libraries = excluded.libraries,
- all_libraries = excluded.all_libraries,
- allow_write_access = excluded.allow_write_access,
- enabled = excluded.enabled,
- last_error = excluded.last_error,
- sha256 = excluded.sha256,
- updated_at = excluded.updated_at
- `).Bind(dbx.Params{
- "id": plugin.ID,
- "path": plugin.Path,
- "manifest": plugin.Manifest,
- "config": plugin.Config,
- "users": plugin.Users,
- "all_users": plugin.AllUsers,
- "libraries": plugin.Libraries,
- "all_libraries": plugin.AllLibraries,
- "allow_write_access": plugin.AllowWriteAccess,
- "enabled": plugin.Enabled,
- "last_error": plugin.LastError,
- "sha256": plugin.SHA256,
- "created_at": time.Now(),
- "updated_at": plugin.UpdatedAt,
- }).Execute()
- 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) Read(ctx context.Context, id string) (*model.Plugin, error) {
- return r.Get(ctx, id)
-}
-
-func (r *pluginRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Plugin, error) {
- return r.GetAll(ctx, r.parseRestOptions(ctx, options...))
-}
-
-var _ model.PluginRepository = (*pluginRepository)(nil)
-var _ rest.Repository[model.Plugin] = (*pluginRepository)(nil)
diff --git a/persistence/plugin_repository_test.go b/persistence/plugin_repository_test.go
deleted file mode 100644
index 44330250b..000000000
--- a/persistence/plugin_repository_test.go
+++ /dev/null
@@ -1,269 +0,0 @@
-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"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("PluginRepository", func() {
- var repo model.PluginRepository
- var ctx context.Context
-
- BeforeEach(func() {
- ctx = GinkgoT().Context()
- })
-
- Describe("Admin User", func() {
- BeforeEach(func() {
- ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
- repo = NewPluginRepository(GetDBXBuilder())
-
- // Clean up any existing plugins
- all, _ := repo.GetAll(ctx)
- for _, p := range all {
- _ = repo.Delete(ctx, p.ID)
- }
- })
-
- AfterEach(func() {
- // Clean up after tests
- all, _ := repo.GetAll(ctx)
- for _, p := range all {
- _ = repo.Delete(ctx, p.ID)
- }
- })
-
- Describe("CountAll", func() {
- It("returns 0 when no plugins exist", func() {
- Expect(repo.CountAll(ctx)).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"})
-
- Expect(repo.CountAll(ctx)).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)
-
- err := repo.Delete(ctx, plugin.ID)
- Expect(err).To(BeNil())
-
- _, err = repo.Get(ctx, plugin.ID)
- Expect(err).To(MatchError(model.ErrNotFound))
- })
- })
-
- 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)
-
- res, err := repo.Get(ctx, plugin.ID)
- Expect(err).To(BeNil())
- Expect(res.ID).To(Equal(plugin.ID))
- Expect(res.Path).To(Equal(plugin.Path))
- Expect(res.Manifest).To(Equal(plugin.Manifest))
- })
-
- It("errors when missing", func() {
- _, err := repo.Get(ctx, "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"})
-
- all, err := repo.GetAll(ctx)
- 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"})
-
- page1, err := repo.GetAll(ctx, 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"})
- Expect(err).To(BeNil())
- Expect(page2).To(HaveLen(1))
- })
- })
-
- Describe("Put", func() {
- It("successfully creates a new plugin", func() {
- plugin := &model.Plugin{
- ID: "new-plugin",
- Path: "/plugins/new.wasm",
- Manifest: `{"name":"new","version":"1.0"}`,
- Config: `{"setting":"value"}`,
- SHA256: "sha256hash",
- Enabled: false,
- }
-
- err := repo.Put(ctx, plugin)
- Expect(err).To(BeNil())
-
- saved, err := repo.Get(ctx, plugin.ID)
- Expect(err).To(BeNil())
- Expect(saved.Path).To(Equal(plugin.Path))
- Expect(saved.Manifest).To(Equal(plugin.Manifest))
- Expect(saved.Config).To(Equal(plugin.Config))
- Expect(saved.Enabled).To(BeFalse())
- Expect(saved.CreatedAt).NotTo(BeZero())
- Expect(saved.UpdatedAt).NotTo(BeZero())
- })
-
- It("successfully updates an existing plugin", func() {
- plugin := &model.Plugin{
- ID: "update-plugin",
- Path: "/plugins/update.wasm",
- Manifest: `{"name":"test"}`,
- SHA256: "original",
- Enabled: false,
- }
- _ = repo.Put(ctx, plugin)
-
- plugin.Enabled = true
- plugin.Config = `{"new":"config"}`
- plugin.SHA256 = "updated"
- err := repo.Put(ctx, plugin)
- Expect(err).To(BeNil())
-
- saved, err := repo.Get(ctx, plugin.ID)
- Expect(err).To(BeNil())
- Expect(saved.Enabled).To(BeTrue())
- Expect(saved.Config).To(Equal(`{"new":"config"}`))
- Expect(saved.SHA256).To(Equal("updated"))
- })
-
- It("stores and retrieves last_error", func() {
- plugin := &model.Plugin{
- ID: "error-plugin",
- Path: "/plugins/error.wasm",
- Manifest: "{}",
- SHA256: "hash",
- LastError: "failed to load: missing export",
- }
- err := repo.Put(ctx, plugin)
- Expect(err).To(BeNil())
-
- saved, err := repo.Get(ctx, plugin.ID)
- Expect(err).To(BeNil())
- Expect(saved.LastError).To(Equal("failed to load: missing export"))
- })
-
- It("fails when ID is empty", func() {
- plugin := &model.Plugin{
- Path: "/plugins/noid.wasm",
- Manifest: "{}",
- SHA256: "hash",
- }
- err := repo.Put(ctx, plugin)
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("ID cannot be empty"))
- })
- })
-
- 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"})
-
- err := repo.ClearErrors(ctx)
- Expect(err).To(BeNil())
-
- all, err := repo.GetAll(ctx)
- Expect(err).To(BeNil())
- for _, p := range all {
- Expect(p.LastError).To(BeEmpty(), "plugin %s should have no error", p.ID)
- }
- })
-
- It("succeeds when no plugins have errors", func() {
- _ = repo.Put(ctx, &model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"})
-
- err := repo.ClearErrors(ctx)
- 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 = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false})
- repo = NewPluginRepository(GetDBXBuilder())
- })
-
- Describe("CountAll", func() {
- It("fails to count items", func() {
- _, err := repo.CountAll(ctx)
- Expect(err).To(Equal(rest.ErrPermissionDenied))
- })
- })
-
- Describe("Delete", func() {
- It("fails to delete items", func() {
- err := repo.Delete(ctx, "any-id")
- Expect(err).To(Equal(rest.ErrPermissionDenied))
- })
- })
-
- Describe("Get", func() {
- It("fails to get items", func() {
- _, err := repo.Get(ctx, "any-id")
- Expect(err).To(Equal(rest.ErrPermissionDenied))
- })
- })
-
- Describe("GetAll", func() {
- It("fails to get all items", func() {
- _, err := repo.GetAll(ctx)
- Expect(err).To(Equal(rest.ErrPermissionDenied))
- })
- })
-
- Describe("Put", func() {
- It("fails to create/update item", func() {
- err := repo.Put(ctx, &model.Plugin{
- ID: "user-create",
- Path: "/plugins/create.wasm",
- Manifest: "{}",
- SHA256: "hash",
- })
- Expect(err).To(Equal(rest.ErrPermissionDenied))
- })
- })
- })
-})
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..cf253d06b 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,119 +26,114 @@ 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) error {
+ if !r.isPermitted() {
return rest.ErrPermissionDenied
}
+ var values map[string]interface{}
+
radio.UpdatedAt = time.Now()
+
if radio.ID == "" {
radio.CreatedAt = time.Now()
radio.ID = id.NewRandom()
+ values, _ = toSQLArgs(*radio)
+ } else {
+ values, _ = toSQLArgs(*radio)
+ update := Update(r.tableName).Where(Eq{"id": radio.ID}).SetMap(values)
+ count, err := r.executeSQL(update)
+
+ if err != nil {
+ return err
+ } else if count > 0 {
+ return nil
+ }
}
- 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
+
+ values["created_at"] = time.Now()
+ insert := Insert(r.tableName).SetMap(values)
+ _, err := r.executeSQL(insert)
+ 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() interface{} {
+ 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) (interface{}, error) {
+ return r.Get(id)
+}
+
+func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+ return r.GetAll(r.parseRestOptions(r.ctx, options...))
+}
+
+func (r *radioRepository) Save(entity interface{}) (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 interface{}, 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..d0f88903e 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,12 +46,12 @@ 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 {
- ins := Insert(r.tableName).SetMap(map[string]any{
+func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error {
+ ins := Insert(r.tableName).SetMap(map[string]interface{}{
"id": id.NewRandom(),
"user_id": userId,
"service": service,
@@ -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..62423ff45 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
@@ -25,7 +24,7 @@ var _ = Describe("ScrobbleBufferRepository", func() {
id := id.NewRandom()
ids = append(ids, id)
- ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]any{
+ ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]interface{}{
"id": id,
"user_id": userId,
"service": service,
@@ -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
deleted file mode 100644
index 3d9882b99..000000000
--- a/persistence/scrobble_repository.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package persistence
-
-import (
- "context"
- "time"
-
- . "github.com/Masterminds/squirrel"
- "github.com/deluan/rest"
- "github.com/navidrome/navidrome/model"
- "github.com/pocketbase/dbx"
-)
-
-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 {
- r := &scrobbleRepository{}
- 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
- 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)
- 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
deleted file mode 100644
index 860b1ad02..000000000
--- a/persistence/scrobble_repository_test.go
+++ /dev/null
@@ -1,218 +0,0 @@
-package persistence
-
-import (
- "context"
- "time"
-
- "github.com/Masterminds/squirrel"
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/id"
- "github.com/navidrome/navidrome/model/request"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
- "github.com/pocketbase/dbx"
-)
-
-var _ = Describe("ScrobbleRepository", func() {
- var repo model.ScrobbleRepository
- var ctx context.Context
-
- 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()
-
- // Insert User
- _, err := rawRepo.db.Insert("user", dbx.Params{
- "id": userID,
- "user_name": "user",
- "password": "pw",
- "created_at": time.Now(),
- "updated_at": time.Now(),
- }).Execute()
- Expect(err).ToNot(HaveOccurred())
-
- // Insert MediaFile
- _, err = rawRepo.db.Insert("media_file", dbx.Params{
- "id": fileID,
- "path": "path",
- "created_at": time.Now(),
- "updated_at": time.Now(),
- }).Execute()
- Expect(err).ToNot(HaveOccurred())
-
- err = repo.RecordScrobble(ctx, fileID, submissionTime)
- Expect(err).ToNot(HaveOccurred())
-
- // Verify insertion
- var scrobble struct {
- MediaFileID string `db:"media_file_id"`
- UserID string `db:"user_id"`
- SubmissionTime int64 `db:"submission_time"`
- }
- err = rawRepo.db.Select("*").From("scrobbles").
- Where(dbx.HashExp{"media_file_id": fileID, "user_id": userID}).
- One(&scrobble)
- Expect(err).ToNot(HaveOccurred())
- Expect(scrobble.MediaFileID).To(Equal(fileID))
- Expect(scrobble.UserID).To(Equal(userID))
- 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..d943943e0 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,43 @@ func NewShareRepository(db dbx.Builder) model.ShareRepository {
return r
}
-func (r *shareRepository) Delete(ctx context.Context, ids ...string) error {
- return r.deleteOwnedAll(ctx, ids...)
+func (r *shareRepository) Delete(id string) error {
+ err := r.delete(Eq{"id": id})
+ if errors.Is(err, model.ErrNotFound) {
+ return rest.ErrNotFound
+ }
+ return err
}
-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) 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) Exists(ctx context.Context, id string) (bool, error) {
- return r.exists(ctx, r.addRestriction(ctx, And{Eq{"id": id}}))
+func (r *shareRepository) Exists(id string) (bool, error) {
+ return r.exists(Eq{"id": id})
}
-func (r *shareRepository) Get(ctx context.Context, id string) (*model.Share, error) {
- sel := r.selectShare(ctx).Where(Eq{"share.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 +74,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 +83,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 +138,65 @@ 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 interface{}, cols ...string) error {
+ s := entity.(*model.Share)
+ // TODO Validate record
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 interface{}) (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() interface{} {
+ return &model.Share{}
+}
+
+func (r *shareRepository) Read(id string) (interface{}, 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) (interface{}, 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..252115175 100644
--- a/persistence/share_repository_test.go
+++ b/persistence/share_repository_test.go
@@ -4,8 +4,6 @@ import (
"context"
"time"
- "github.com/Masterminds/squirrel"
- "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -21,12 +19,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 +35,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())
})
@@ -55,7 +47,7 @@ var _ = Describe("ShareRepository", func() {
_, 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{
+ `).Bind(map[string]interface{}{
"id": shareID,
"user": adminUser.ID,
"desc": "Headless Test Share",
@@ -67,8 +59,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
@@ -87,7 +79,7 @@ var _ = Describe("ShareRepository", func() {
_, 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{
+ `).Bind(map[string]interface{}{
"id": shareID,
"user": adminUser.ID,
"desc": "Headless Get Share",
@@ -99,8 +91,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"))
@@ -118,7 +110,7 @@ var _ = Describe("ShareRepository", func() {
_, 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{
+ `).Bind(map[string]interface{}{
"id": shareID,
"user": adminUser.ID,
"desc": "SQL Test Share",
@@ -131,501 +123,11 @@ 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
Expect(share.Albums).To(BeEmpty())
})
})
-
- 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"}
-
- insertShare := func(shareID, userID string) {
- _, 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": shareID,
- "user": userID,
- "desc": "Test Share",
- "type": "media_file",
- "ids": "1001",
- "created": time.Now(),
- "updated": time.Now(),
- }).Execute()
- Expect(err).ToNot(HaveOccurred())
- }
-
- 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")
- 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")
- 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")
- 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")
- 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")
- 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")
- 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")
- 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")
- 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
deleted file mode 100644
index 871f53dd2..000000000
--- a/persistence/smart_playlist_repository.go
+++ /dev/null
@@ -1,205 +0,0 @@
-package persistence
-
-import (
- "context"
- "slices"
- "time"
-
- . "github.com/Masterminds/squirrel"
- "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
-// based on their rules. The main method is refreshSmartPlaylist, which evaluates the criteria and updates the playlist
-// tracks accordingly. It also handles refreshing dependent playlists when a smart playlist references other playlists
-// in its criteria. To optimize performance, it only refreshes when necessary based on the last evaluated time and
-// 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) {
- return false
- }
-
- log.Debug(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)
- return false
- }
-
- rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr))
-
- if !r.refreshChildPlaylists(ctx, pls, rulesSQL, visited) {
- return false
- }
-
- if err := r.resolvePercentageLimit(ctx, pls, &rulesSQL, usr.ID); err != nil {
- return false
- }
-
- sq := r.buildSmartPlaylistQuery(ctx, 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)
- 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)
- 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)
- return false
- }
-
- // Reuse the stamp refreshCounters just wrote, so evaluated_at and updated_at agree
- now := pls.UpdatedAt
- 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)
- return false
- }
- pls.EvaluatedAt = &now
-
- log.Debug(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 {
- if !pls.IsSmartPlaylist() {
- return false
- }
- if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < pls.RefreshDelay() {
- return false
- }
- if pls.OwnerID != usr.ID {
- log.Trace(ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
- return false
- }
- return true
-}
-
-// 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 {
- childPlaylistIds := rulesSQL.ChildPlaylistIds()
- childPlaylistPaths := rulesSQL.ChildPlaylistPaths()
- if len(childPlaylistIds) == 0 && len(childPlaylistPaths) == 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})
- 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)
- return false
- }
-
- found := make(map[string]struct{}, len(childPlaylists)*2)
- 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)
- }
- 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)
- }
- }
- 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 {
- if !rulesSQL.IsPercentageLimit() {
- return nil
- }
-
- countSq := Select("count(*) as count").From("media_file")
- countSq = rulesSQL.applyExpressionJoins(countSq, userID)
- countSq = r.applyLibraryFilter(ctx, countSq, "media_file")
-
- cond, err := rulesSQL.where()
- if err != nil {
- log.Error(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)
- return err
- }
-
- rulesSQL.ResolveLimit(res.Count)
- log.Debug(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()
- 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")
- 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()
- if err != nil {
- return sql, err
- }
- sql = sql.Where(cond)
- if cSQL.Criteria.Limit > 0 {
- sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset))
- }
- 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
deleted file mode 100644
index 4d4c5edb0..000000000
--- a/persistence/smart_playlist_repository_test.go
+++ /dev/null
@@ -1,810 +0,0 @@
-package persistence
-
-import (
- "context"
- "path/filepath"
- "time"
-
- "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 - 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())
- })
-
- Context("Smart Playlists", func() {
- var rules *criteria.Criteria
- BeforeEach(func() {
- rules = &criteria.Criteria{
- Expression: criteria.All{
- criteria.Contains{"title": "love"},
- },
- }
- })
- 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) })
-
- savedPls, err := repo.Get(ctx, 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{
- // This is invalid because "contains" cannot have multiple fields
- Expression: criteria.All{
- criteria.Contains{"genre": "Hardcore", "filetype": "mp3"},
- },
- }
- 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))
- })
- })
-
- Context("child smart playlists", func() {
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- })
-
- When("refresh delay has expired", func() {
- It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
- conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
-
- childRules := &criteria.Criteria{
- Expression: criteria.All{
- 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) })
-
- parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
- Expression: criteria.Any{
- criteria.InPlaylist{"id": nestedPls.ID},
- criteria.InPlaylist{"path": nestedPathPls.Path},
- },
- }}
- Expect(repo.Put(ctx, &parentPls)).To(Succeed())
- DeferCleanup(func() { _ = repo.Delete(ctx, parentPls.ID) })
-
- // Nested playlist has not been evaluated yet
- nestedPlsRead, err := repo.Get(ctx, 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)
- 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[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)
- 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
- childEvaluatedAt := time.Now().Add(-30 * time.Minute)
-
- childRules := &criteria.Criteria{
- Expression: criteria.All{
- criteria.Contains{"title": "Day"},
- },
- }
- 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) })
-
- // Parent has no EvaluatedAt, so it WILL refresh, but the child should not
- parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
- Expression: criteria.All{
- criteria.InPlaylist{"id": nestedPls.ID},
- },
- }}
- Expect(repo.Put(ctx, &parentPls)).To(Succeed())
- DeferCleanup(func() { _ = repo.Delete(ctx, parentPls.ID) })
-
- nestedPlsRead, err := repo.Get(ctx, 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)
- Expect(err).ToNot(HaveOccurred())
-
- // Parent should have been refreshed (its EvaluatedAt was nil)
- Expect(parent.EvaluatedAt).ToNot(BeNil())
- 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)
- 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))
- })
- })
- })
- })
-
- Describe("Playlist Track Sorting", func() {
- var testPlaylistID string
-
- AfterEach(func() {
- if testPlaylistID != "" {
- Expect(repo.Delete(ctx, testPlaylistID)).To(BeNil())
- testPlaylistID = ""
- }
- })
-
- It("sorts tracks correctly by album (disc and track number)", func() {
- By("creating a playlist with multi-disc album tracks in arbitrary order")
- 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())
- 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"})
- Expect(err).ToNot(HaveOccurred())
-
- By("verifying tracks are sorted by disc number then track number")
- Expect(tracks).To(HaveLen(4))
- // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
- Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
- Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
- Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
- Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
- })
- })
-
- Describe("Smart Playlists with Album/Artist Annotation Criteria", func() {
- var testPlaylistID string
-
- AfterEach(func() {
- if testPlaylistID != "" {
- _ = repo.Delete(ctx, testPlaylistID)
- testPlaylistID = ""
- }
- })
-
- It("matches tracks from starred albums using albumLoved", func() {
- // albumRadioactivity (ID "103") is starred in test fixtures
- // Songs in album 103: 1003, 1004, 1005, 1006
- rules := &criteria.Criteria{
- Expression: criteria.All{
- criteria.Is{"albumLoved": true},
- },
- }
- newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(ctx, &newPls)).To(Succeed())
- testPlaylistID = newPls.ID
-
- conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
- pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false)
- Expect(err).ToNot(HaveOccurred())
-
- trackIDs := make([]string, len(pls.Tracks))
- for i, t := range pls.Tracks {
- trackIDs[i] = t.MediaFileID
- }
- Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006"))
- })
-
- It("matches tracks from starred artists using artistLoved", func() {
- // artistBeatles (ID "3") is starred in test fixtures
- // Songs with ArtistID "3": 1001, 1002, 3002
- rules := &criteria.Criteria{
- Expression: criteria.All{
- criteria.Is{"artistLoved": true},
- },
- }
- newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(ctx, &newPls)).To(Succeed())
- testPlaylistID = newPls.ID
-
- conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
- pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false)
- Expect(err).ToNot(HaveOccurred())
-
- trackIDs := make([]string, len(pls.Tracks))
- for i, t := range pls.Tracks {
- trackIDs[i] = t.MediaFileID
- }
- Expect(trackIDs).To(ConsistOf("1001", "1002", "3002"))
- })
-
- It("matches tracks with combined album and artist criteria", func() {
- // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006)
- // artistLoved=true → songs with artist 3 (1001, 1002)
- // Using Any: union of both sets
- rules := &criteria.Criteria{
- Expression: criteria.Any{
- criteria.Is{"albumLoved": true},
- criteria.Is{"artistLoved": true},
- },
- }
- newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(ctx, &newPls)).To(Succeed())
- testPlaylistID = newPls.ID
-
- conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
- pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false)
- Expect(err).ToNot(HaveOccurred())
-
- trackIDs := make([]string, len(pls.Tracks))
- for i, t := range pls.Tracks {
- trackIDs[i] = t.MediaFileID
- }
- Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002"))
- })
-
- It("returns no tracks when no albums/artists match", func() {
- // No album has rating 5 in fixtures
- rules := &criteria.Criteria{
- Expression: criteria.All{
- criteria.Is{"albumRating": 5},
- },
- }
- newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(ctx, &newPls)).To(Succeed())
- testPlaylistID = newPls.ID
-
- conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
- pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false)
- Expect(err).ToNot(HaveOccurred())
-
- Expect(pls.Tracks).To(BeEmpty())
- })
-
- It("matches loved tracks when loved value is a string in nested group (issue #4826)", func() {
- // songComeTogether (ID "1002") is starred in test fixtures
- rules := &criteria.Criteria{
- Expression: criteria.All{
- criteria.Any{
- criteria.Is{"loved": "true"},
- },
- },
- }
- newPls := model.Playlist{Name: "String Loved Nested", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(ctx, &newPls)).To(Succeed())
- testPlaylistID = newPls.ID
-
- conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
- pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false)
- Expect(err).ToNot(HaveOccurred())
-
- trackIDs := make([]string, len(pls.Tracks))
- for i, t := range pls.Tracks {
- trackIDs[i] = t.MediaFileID
- }
- Expect(trackIDs).To(ContainElement("1002"))
- Expect(len(pls.Tracks)).To(BeNumerically(">=", 1))
- })
-
- It("returns same results for string and bool loved values (issue #4826)", func() {
- boolRules := &criteria.Criteria{
- Expression: criteria.All{
- criteria.Any{
- criteria.Is{"loved": true},
- },
- },
- }
- boolPls := model.Playlist{Name: "Bool Loved", OwnerID: "userid", Rules: boolRules}
- Expect(repo.Put(ctx, &boolPls)).To(Succeed())
- DeferCleanup(func() { _ = repo.Delete(ctx, boolPls.ID) })
-
- stringRules := &criteria.Criteria{
- Expression: criteria.All{
- criteria.Any{
- criteria.Is{"loved": "true"},
- },
- },
- }
- stringPls := model.Playlist{Name: "String Loved", OwnerID: "userid", Rules: stringRules}
- Expect(repo.Put(ctx, &stringPls)).To(Succeed())
- testPlaylistID = stringPls.ID
-
- conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
- boolResult, err := repo.GetWithTracks(ctx, boolPls.ID, true, false)
- Expect(err).ToNot(HaveOccurred())
- stringResult, err := repo.GetWithTracks(ctx, stringPls.ID, true, false)
- Expect(err).ToNot(HaveOccurred())
-
- boolIDs := make([]string, len(boolResult.Tracks))
- for i, t := range boolResult.Tracks {
- boolIDs[i] = t.MediaFileID
- }
- stringIDs := make([]string, len(stringResult.Tracks))
- for i, t := range stringResult.Tracks {
- stringIDs[i] = t.MediaFileID
- }
- Expect(stringIDs).To(ConsistOf(boolIDs))
- })
- })
-
- 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
- var songWithGrouping, songWithoutGrouping model.MediaFile
-
- BeforeEach(func() {
- ctx := log.NewContext(GinkgoT().Context())
- ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
- mfRepo = NewMediaFileRepository(GetDBXBuilder())
-
- // Register 'grouping' as a valid tag for smart playlists
- criteria.AddTagNames([]string{"grouping"})
-
- // Create a song with the grouping tag
- songWithGrouping = model.MediaFile{
- ID: "test-grouping-1",
- Title: "Song With Grouping",
- Artist: "Test Artist",
- ArtistID: "1",
- Album: "Test Album",
- AlbumID: "101",
- Path: "test/grouping/song1.mp3",
- Tags: model.Tags{
- "grouping": []string{"My Crate"},
- },
- Participants: model.Participants{},
- LibraryID: 1,
- Lyrics: "[]",
- }
- Expect(mfRepo.Put(ctx, &songWithGrouping)).To(Succeed())
-
- // Create a song without the grouping tag
- songWithoutGrouping = model.MediaFile{
- ID: "test-grouping-2",
- Title: "Song Without Grouping",
- Artist: "Test Artist",
- ArtistID: "1",
- Album: "Test Album",
- AlbumID: "101",
- Path: "test/grouping/song2.mp3",
- Tags: model.Tags{},
- Participants: model.Participants{},
- LibraryID: 1,
- Lyrics: "[]",
- }
- Expect(mfRepo.Put(ctx, &songWithoutGrouping)).To(Succeed())
- })
-
- AfterEach(func() {
- if testPlaylistID != "" {
- _ = repo.Delete(ctx, testPlaylistID)
- testPlaylistID = ""
- }
- // Clean up test media files
- _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
- _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
- })
-
- It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
- By("creating a smart playlist that checks if grouping tag has any value")
- // This is the workaround for issue #4728: using 'contains' with empty string
- // generates SQL: value LIKE '%%' which matches any non-empty string
- rules := &criteria.Criteria{
- Expression: criteria.All{
- criteria.Contains{"grouping": ""},
- },
- }
- newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(ctx, &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)
- Expect(err).ToNot(HaveOccurred())
-
- By("verifying only the track with grouping tag is matched")
- Expect(pls.Tracks).To(HaveLen(1))
- Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
- })
-
- It("excludes tracks with a tag value using 'notContains' with empty string", func() {
- By("creating a smart playlist that checks if grouping tag is NOT set")
- rules := &criteria.Criteria{
- Expression: criteria.All{
- criteria.NotContains{"grouping": ""},
- },
- }
- newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
- Expect(repo.Put(ctx, &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)
- Expect(err).ToNot(HaveOccurred())
-
- By("verifying the track with grouping is NOT in the playlist")
- for _, track := range pls.Tracks {
- Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
- }
-
- By("verifying the track without grouping IS in the playlist")
- var foundWithoutGrouping bool
- for _, track := range pls.Tracks {
- if track.MediaFileID == songWithoutGrouping.ID {
- foundWithoutGrouping = true
- break
- }
- }
- Expect(foundWithoutGrouping).To(BeTrue())
- })
- })
-
- Describe("Smart Playlists Library Filtering", func() {
- var mfRepo model.MediaFileRepository
- var testPlaylistID string
- var lib2ID int
- var restrictedUserID string
- var uniqueLibPath string
-
- BeforeEach(func() {
- db := GetDBXBuilder()
-
- // Generate unique IDs for this test run
- uniqueSuffix := time.Now().Format("20060102150405.000")
- restrictedUserID = "restricted-user-" + uniqueSuffix
- uniqueLibPath = "/music/lib2-" + uniqueSuffix
-
- // Create a second library with unique name and path to avoid conflicts with other tests
- _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath)
- Expect(err).ToNot(HaveOccurred())
- err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID)
- Expect(err).ToNot(HaveOccurred())
-
- // Create a restricted user with access only to library 1
- _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID)
- Expect(err).ToNot(HaveOccurred())
- _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID)
- Expect(err).ToNot(HaveOccurred())
-
- // 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)
-
- // Song in library 1 (accessible by restricted user)
- songLib1 := model.MediaFile{
- ID: "lib1-song",
- Title: "Song in Lib1",
- Artist: "Test Artist",
- ArtistID: "1",
- Album: "Test Album",
- AlbumID: "101",
- Path: "lib1/song.mp3",
- LibraryID: 1,
- Participants: model.Participants{},
- Tags: model.Tags{},
- Lyrics: "[]",
- }
- Expect(mfRepo.Put(ctx, &songLib1)).To(Succeed())
-
- // Song in library 2 (NOT accessible by restricted user)
- songLib2 := model.MediaFile{
- ID: "lib2-song",
- Title: "Song in Lib2",
- Artist: "Test Artist",
- ArtistID: "1",
- Album: "Test Album",
- AlbumID: "101",
- Path: "lib2/song.mp3",
- LibraryID: lib2ID,
- Participants: model.Participants{},
- Tags: model.Tags{},
- Lyrics: "[]",
- }
- Expect(mfRepo.Put(ctx, &songLib2)).To(Succeed())
- })
-
- AfterEach(func() {
- db := GetDBXBuilder()
- if testPlaylistID != "" {
- _ = repo.Delete(ctx, testPlaylistID)
- testPlaylistID = ""
- }
- // Clean up test data
- _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute()
- _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute()
- _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute()
- _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute()
- _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID)
- })
-
- It("should only include tracks from libraries the user has access to (issue #4738)", func() {
- db := GetDBXBuilder()
- ctx := log.NewContext(GinkgoT().Context())
-
- // 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)
-
- // Create a smart playlist that matches all songs
- rules := &criteria.Criteria{
- Expression: criteria.All{
- criteria.Gt{"playCount": -1}, // Matches everything
- },
- }
- newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules}
- Expect(restrictedRepo.Put(ctx, &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)
- Expect(err).ToNot(HaveOccurred())
-
- By("verifying only the track from library 1 is in the playlist")
- var foundLib1Song, foundLib2Song bool
- for _, track := range pls.Tracks {
- if track.MediaFileID == "lib1-song" {
- foundLib1Song = true
- }
- if track.MediaFileID == "lib2-song" {
- foundLib2Song = true
- }
- }
- Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist")
- Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist")
-
- By("verifying playlist_tracks table only contains the accessible track")
- var playlistTracksCount int
- err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount)
- Expect(err).ToNot(HaveOccurred())
- // Count should only include tracks visible to the user (lib1-song)
- // The count may include other test songs from library 1, but NOT lib2-song
- var lib2TrackCount int
- err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount)
- Expect(err).ToNot(HaveOccurred())
- Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks")
-
- By("verifying SongCount matches visible tracks")
- Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks")
- })
- })
-})
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..6691b553c 100644
--- a/persistence/sql_annotations.go
+++ b/persistence/sql_annotations.go
@@ -1,81 +1,33 @@
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))
+ return query
}
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",
"starred_at",
"play_date",
- "rated_at",
)
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
query = query.Columns(
@@ -85,26 +37,11 @@ func (r sqlRepository) withAnnotation(ctx context.Context, query SelectBuilder,
query = query.Columns("coalesce(play_count, 0) as play_count")
}
- query = query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
-
return query
}
-func annotationBoolFilter(field string) func(string, any) Sqlizer {
- return func(_ string, value any) Sqlizer {
- v, ok := value.(string)
- if !ok {
- return nil
- }
- if strings.ToLower(v) == "true" {
- return Expr(fmt.Sprintf("COALESCE(%s, 0) > 0", field))
- }
- return Expr(fmt.Sprintf("COALESCE(%s, 0) = 0", field))
- }
-}
-
-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 +49,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]interface{}, 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,47 +71,31 @@ 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]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
}
-func (r sqlRepository) SetRating(ctx context.Context, rating int, itemID string) error {
- ratedAt := time.Now()
- err := r.annUpsert(ctx, map[string]any{"rating": rating, "rated_at": ratedAt}, itemID)
- if err != nil {
- return err
- }
- return r.updateAvgRating(ctx, itemID)
+func (r sqlRepository) SetRating(rating int, itemID string) error {
+ return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
}
-func (r sqlRepository) updateAvgRating(ctx context.Context, 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)
- 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
- values := map[string]any{}
+ userID := loggedUser(r.ctx).ID
+ values := map[string]interface{}{}
values["user_id"] = userID
values["item_type"] = r.tableName
values["item_id"] = itemID
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 +103,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)
+ return fmt.Errorf("error cleaning up annotations: %w", 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
deleted file mode 100644
index 6761331d9..000000000
--- a/persistence/sql_annotations_test.go
+++ /dev/null
@@ -1,297 +0,0 @@
-package persistence
-
-import (
- "context"
-
- "github.com/Masterminds/squirrel"
- "github.com/deluan/rest"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-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)
-
- // 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())
- })
-
- 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))
- })
- })
-
- Describe("annotationBoolFilter", func() {
- DescribeTable("creates correct SQL expressions",
- func(field, value string, expectedSQL string, expectedArgs []any) {
- sqlizer := annotationBoolFilter(field)(field, value)
- sql, args, err := sqlizer.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(Equal(expectedSQL))
- Expect(args).To(Equal(expectedArgs))
- },
- Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []any(nil)),
- 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() {
- sqlizer := annotationBoolFilter("starred")("starred", 123)
- Expect(sqlizer).To(BeNil())
- })
- })
-
- Describe("starredFilter", func() {
- It("false includes items without annotations", func() {
- albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
- Filters: annotationBoolFilter("starred")("starred", "false"),
- })
- Expect(err).ToNot(HaveOccurred())
-
- var found bool
- for _, a := range albums {
- if a.ID == albumWithoutAnnotation.ID {
- found = true
- break
- }
- }
- Expect(found).To(BeTrue(), "Item without annotation should be included in starred=false filter")
- })
-
- It("true excludes items without annotations", func() {
- albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
- Filters: annotationBoolFilter("starred")("starred", "true"),
- })
- Expect(err).ToNot(HaveOccurred())
-
- for _, a := range albums {
- Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
- }
- })
- })
-
- Describe("hasRatingFilter", func() {
- It("false includes items without annotations", func() {
- albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
- Filters: annotationBoolFilter("rating")("rating", "false"),
- })
- Expect(err).ToNot(HaveOccurred())
-
- var found bool
- for _, a := range albums {
- if a.ID == albumWithoutAnnotation.ID {
- found = true
- break
- }
- }
- Expect(found).To(BeTrue(), "Item without annotation should be included in has_rating=false filter")
- })
-
- It("true excludes items without annotations", func() {
- albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
- Filters: annotationBoolFilter("rating")("rating", "true"),
- })
- Expect(err).ToNot(HaveOccurred())
-
- for _, a := range albums {
- Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
- }
- })
-
- 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())
- 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}))
- }()
-
- albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
- Filters: annotationBoolFilter("rating")("rating", "true"),
- })
- Expect(err).ToNot(HaveOccurred())
-
- var found bool
- for _, a := range albums {
- if a.ID == ratedAlbum.ID {
- found = true
- break
- }
- }
- Expect(found).To(BeTrue(), "Album with rating 5 should be included in has_rating=true filter")
- })
- })
-
- It("ignores invalid filter values (not strings)", func() {
- res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
- Filters: map[string]any{"starred": 123},
- })
- 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(), "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..ce026a3c3 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,27 +186,30 @@ 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 {
+func libraryIdFilter(_ string, value interface{}) Sqlizer {
return Eq{"library_id": value}
}
// 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 interface{}) 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 interface{}, 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 interface{}) 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 interface{}, 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 interface{}, 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]interface{}{}
+
+ // 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..52c4b8e9c 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,28 +26,28 @@ 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)
- values := map[string]any{
+func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error {
+ client, _ := request.ClientFrom(r.ctx)
+ user, _ := request.UserFrom(r.ctx)
+ values := map[string]interface{}{
"comment": comment,
"position": position,
"updated_at": time.Now(),
"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)
+ return fmt.Errorf("error cleaning up bookmarks: %w", err)
}
if c > 0 {
- log.Debug(ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName)
+ log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c)
}
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..d88eca45e 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,10 @@ func unmarshalParticipants(data string) (model.Participants, error) {
return participants, nil
}
-func (r sqlRepository) updateParticipants(ctx context.Context, 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)
+func (r sqlRepository) updateParticipants(itemID string, participants model.Participants) error {
+ ids := participants.AllIDs()
+ sqd := Delete(r.tableName + "_artists").Where(And{Eq{r.tableName + "_id": itemID}, NotEq{"artist_id": ids}})
+ _, err := r.executeSQL(sqd)
if err != nil {
return err
}
@@ -120,14 +93,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..ff0d06a8b 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 {
@@ -109,10 +109,11 @@ func booleanFilter(field string, value any) Sqlizer {
func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer {
return func(field string, value any) Sqlizer {
v := strings.ToLower(value.(string))
- return cmp.Or[Sqlizer](
+ cond := cmp.Or(
mbidExpr(tableName, v, mbidFields...),
- getSearchStrategy(tableName, v),
+ fullTextExpr(tableName, v),
)
+ return cond
}
}
@@ -129,9 +130,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_restful_test.go b/persistence/sql_restful_test.go
index 32f418b8e..fd95fbb31 100644
--- a/persistence/sql_restful_test.go
+++ b/persistence/sql_restful_test.go
@@ -26,13 +26,11 @@ var _ = Describe("sqlRestful", func() {
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
})
- It(`returns nil if tries a filter with legacySearchExpr("'")`, func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.Search.Backend = "legacy"
+ It(`returns nil if tries a filter with fullTextExpr("'")`, func() {
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter("table"),
}
- options.Filters = map[string]any{"name": "'"}
+ options.Filters = map[string]interface{}{"name": "'"}
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
})
@@ -42,32 +40,32 @@ var _ = Describe("sqlRestful", func() {
return nil
},
}
- options.Filters = map[string]any{"name": "joe"}
+ options.Filters = map[string]interface{}{"name": "joe"}
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
})
It("returns a '=' condition for 'id' filter", func() {
- options.Filters = map[string]any{"id": "123"}
+ options.Filters = map[string]interface{}{"id": "123"}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
})
It("returns a 'in' condition for multiples 'id' filters", func() {
- options.Filters = map[string]any{"id": []string{"123", "456"}}
+ options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
})
It("returns a 'like' condition for other filters", func() {
- options.Filters = map[string]any{"name": "joe"}
+ options.Filters = map[string]interface{}{"name": "joe"}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
})
It("uses the custom filter", func() {
r.filterMappings = map[string]filterFunc{
- "test": func(field string, value any) squirrel.Sqlizer {
+ "test": func(field string, value interface{}) squirrel.Sqlizer {
return squirrel.Gt{field: value}
},
}
- options.Filters = map[string]any{"test": 100}
+ options.Filters = map[string]interface{}{"test": 100}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
})
})
@@ -79,7 +77,6 @@ var _ = Describe("sqlRestful", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- conf.Server.Search.Backend = "legacy"
tableName = "test_table"
mbidFields = []string{"mbid", "artist_mbid"}
filter = fullTextFilter(tableName, mbidFields...)
@@ -102,11 +99,11 @@ var _ = Describe("sqlRestful", func() {
uuid := "550e8400-e29b-41d4-a716-446655440000"
result := noMbidFilter("search", uuid)
- // mbidExpr with no fields returns nil, so cmp.Or falls back to search strategy
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
- Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%"))
+ // mbidExpr with no fields returns nil, so cmp.Or falls back to fullTextExpr
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"},
+ }
+ Expect(result).To(Equal(expected))
})
})
@@ -114,75 +111,54 @@ var _ = Describe("sqlRestful", func() {
It("returns full text search condition only", func() {
result := filter("search", "beatles")
- // mbidExpr returns nil for non-UUIDs, so search strategy result is returned directly
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
- Expect(args).To(ContainElement("% beatles%"))
+ // mbidExpr returns nil for non-UUIDs, so fullTextExpr result is returned directly
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% beatles%"},
+ }
+ Expect(result).To(Equal(expected))
})
It("handles multi-word search terms", func() {
result := filter("search", "the beatles abbey road")
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- // All words should be present as LIKE conditions
- Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
- Expect(args).To(HaveLen(4))
- Expect(args).To(ContainElement("% the%"))
- Expect(args).To(ContainElement("% beatles%"))
- Expect(args).To(ContainElement("% abbey%"))
- Expect(args).To(ContainElement("% road%"))
+ // Should return And condition directly
+ andCondition, ok := result.(squirrel.And)
+ Expect(ok).To(BeTrue())
+ Expect(andCondition).To(HaveLen(4))
+
+ // Check that all words are present (order may vary)
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"}))
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"}))
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"}))
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"}))
})
})
Context("when SearchFullString config changes behavior", func() {
It("uses different separator with SearchFullString=false", func() {
- conf.Server.Search.FullString = false
+ conf.Server.SearchFullString = false
result := filter("search", "test query")
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
- Expect(args).To(HaveLen(2))
- Expect(args).To(ContainElement("% test%"))
- Expect(args).To(ContainElement("% query%"))
+ andCondition, ok := result.(squirrel.And)
+ Expect(ok).To(BeTrue())
+ Expect(andCondition).To(HaveLen(2))
+
+ // Check that all words are present with leading space (order may vary)
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"}))
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"}))
})
It("uses no separator with SearchFullString=true", func() {
- conf.Server.Search.FullString = true
+ conf.Server.SearchFullString = true
result := filter("search", "test query")
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
- Expect(args).To(HaveLen(2))
- Expect(args).To(ContainElement("%test%"))
- Expect(args).To(ContainElement("%query%"))
- })
- })
+ andCondition, ok := result.(squirrel.And)
+ Expect(ok).To(BeTrue())
+ Expect(andCondition).To(HaveLen(2))
- Context("single-character queries (regression: must not be rejected)", func() {
- It("returns valid filter for single-char query with legacy backend", func() {
- conf.Server.Search.Backend = "legacy"
- result := filter("search", "a")
- Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("LIKE"))
- Expect(args).ToNot(BeEmpty())
- })
-
- It("returns valid filter for single-char query with FTS backend", func() {
- conf.Server.Search.Backend = "fts"
- conf.Server.Search.FullString = false
- ftsFilter := fullTextFilter(tableName, mbidFields...)
- result := ftsFilter("search", "a")
- Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("MATCH"))
- Expect(args).ToNot(BeEmpty())
+ // Check that all words are present without leading space (order may vary)
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"}))
+ Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"}))
})
})
@@ -200,10 +176,10 @@ var _ = Describe("sqlRestful", func() {
It("handles special characters that are sanitized", func() {
result := filter("search", "don't")
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
- Expect(args).To(ContainElement("% dont%"))
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes
+ }
+ Expect(result).To(Equal(expected))
})
It("returns nil for single quote (SQL injection protection)", func() {
@@ -227,30 +203,31 @@ var _ = Describe("sqlRestful", func() {
result := filter("search", "550e8400-invalid-uuid")
// Should return full text filter since UUID is invalid
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
- Expect(args).To(ContainElement("% 550e8400-invalid-uuid%"))
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"},
+ }
+ Expect(result).To(Equal(expected))
})
It("handles empty mbid fields array", func() {
emptyMbidFilter := fullTextFilter(tableName, []string{}...)
result := emptyMbidFilter("search", "test")
- // mbidExpr with empty fields returns nil, so search strategy result is returned directly
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
- Expect(args).To(ContainElement("% test%"))
+ // mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% test%"},
+ }
+ Expect(result).To(Equal(expected))
})
It("converts value to lowercase before processing", func() {
result := filter("search", "TEST")
- sql, args, err := result.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
- Expect(args).To(ContainElement("% test%"))
+ // The function converts to lowercase internally
+ expected := squirrel.And{
+ squirrel.Like{"test_table.full_text": "% test%"},
+ }
+ Expect(result).To(Equal(expected))
})
})
})
diff --git a/persistence/sql_search.go b/persistence/sql_search.go
index aa8707247..0d3bfb743 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"
@@ -17,116 +15,36 @@ func formatFullText(text ...string) string {
return " " + fullText
}
-// searchConfig holds per-repository constants for doSearch.
-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 func(sq SelectBuilder) SelectBuilder
-}
-
-// searchStrategy defines how to execute a text search against a repository table.
-// options carries filters and pagination that must reach all query phases,
-// 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
-}
-
-// getSearchStrategy returns the appropriate search strategy based on config and query content.
-// Returns nil when the query produces no searchable tokens.
-func getSearchStrategy(tableName, query string) searchStrategy {
- if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString {
- return newLegacySearch(tableName, query)
- }
- if containsCJK(query) {
- return newLikeSearch(tableName, query)
- }
- return newFTSSearch(tableName, query)
-}
-
-// doSearch dispatches a search query: empty → natural order, UUID → MBID match,
-// 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 {
+// doSearch performs a full-text search with the specified parameters.
+// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like
+// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter
+// should be `tableName + ".rowid"`, but some repositories (ex: artist) may use a different natural order.
+func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, results any, naturalOrder string, orderBys ...string) error {
q = strings.TrimSpace(q)
q = strings.TrimSuffix(q, "*")
-
- sq = sq.Where(Eq{r.tableName + ".missing": false})
-
- // 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)
- }
-
- // 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)
- }
-
- // Min-length guard: single-character queries are too broad for search3.
- // This check lives here (not in the strategies) so that fullTextFilter
- // (REST filter path) can still use single-character queries.
if len(q) < 2 {
return nil
}
- strategy := getSearchStrategy(r.tableName, q)
- if strategy == nil {
- 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)
+ filter := fullTextExpr(r.tableName, q)
+ if filter != nil {
+ sq = sq.Where(filter)
+ sq = sq.OrderBy(orderBys...)
} else {
- rowidQuery = r.applyLibraryFilter(ctx, rowidQuery)
+ // This is to speed up the results of `search3?query=""`, for OpenSubsonic
+ // If the filter is empty, we sort by the specified natural order.
+ sq = sq.OrderBy(naturalOrder)
}
- if options.Filters != nil {
- rowidQuery = rowidQuery.Where(options.Filters)
- }
- return r.hydrateRowidPage(ctx, sq, rowidQuery, results)
+ sq = sq.Where(Eq{r.tableName + ".missing": false})
+ sq = sq.Limit(uint64(size)).Offset(uint64(offset))
+ return r.queryAll(sq, results, model.QueryOptions{Offset: offset})
}
-// 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)
+func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, results any) error {
+ sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...))
+ sq = sq.Where(Eq{r.tableName + ".missing": false})
+
+ return r.queryAll(sq, results)
}
func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
@@ -140,3 +58,20 @@ func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
}
return Or(cond)
}
+
+func fullTextExpr(tableName string, s string) Sqlizer {
+ q := str.SanitizeStrings(s)
+ if q == "" {
+ return nil
+ }
+ var sep string
+ if !conf.Server.SearchFullString {
+ sep = " "
+ }
+ parts := strings.Split(q, " ")
+ filters := And{}
+ for _, part := range parts {
+ filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
+ }
+ return filters
+}
diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go
deleted file mode 100644
index ee43680e6..000000000
--- a/persistence/sql_search_fts.go
+++ /dev/null
@@ -1,389 +0,0 @@
-package persistence
-
-import (
- "context"
- "fmt"
- "regexp"
- "strings"
- "unicode"
- "unicode/utf8"
-
- . "github.com/Masterminds/squirrel"
- "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.
-// CJK text doesn't use spaces between words, so FTS5's unicode61 tokenizer treats entire
-// CJK phrases as single tokens, making token-based search ineffective for CJK content.
-func containsCJK(s string) bool {
- for _, r := range s {
- if unicode.Is(unicode.Han, r) ||
- unicode.Is(unicode.Hiragana, r) ||
- unicode.Is(unicode.Katakana, r) ||
- unicode.Is(unicode.Hangul, r) {
- return true
- }
- }
- return false
-}
-
-// fts5SpecialChars matches characters that should be stripped from user input.
-// We keep only Unicode letters, numbers, whitespace, * (prefix wildcard), " (phrase quotes),
-// and \x00 (internal placeholder marker). All punctuation is removed because the unicode61
-// tokenizer treats it as token separators, and characters like ' can cause FTS5 parse errors
-// as unbalanced string delimiters.
-var fts5SpecialChars = regexp.MustCompile(`[^\p{L}\p{N}\s*"\x00]`)
-
-// 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])\*+`)
-
-// isSingleUnicodeLetter returns true if token is exactly one Unicode letter.
-func isSingleUnicodeLetter(token string) bool {
- r, size := utf8.DecodeRuneInString(token)
- return size == len(token) && size > 0 && unicode.IsLetter(r)
-}
-
-// namePunctuation is the set of characters commonly used as separators in artist/album
-// names (hyphens, slashes, dots, apostrophes). Only words containing these are candidates
-// for punctuated-word processing; other special characters (^, :, &) are just stripped.
-const namePunctuation = `-/.''`
-
-// processPunctuatedWords handles words with embedded name punctuation before the general
-// special-character stripping. For each punctuated word it produces either:
-// - A quoted phrase for dotted abbreviations: R.E.M. → "R E M"
-// - A phrase+concat OR for other patterns: a-ha → ("a ha" OR aha*)
-func processPunctuatedWords(input string, phrases []string) (string, []string) {
- words := strings.Fields(input)
- var result []string
- for _, w := range words {
- if strings.HasPrefix(w, "\x00") || strings.ContainsAny(w, `*"`) || !strings.ContainsAny(w, namePunctuation) {
- result = append(result, w)
- continue
- }
- concat := str.FTSPunctStrip.ReplaceAllString(w, "")
- if concat == "" || concat == w {
- result = append(result, w)
- continue
- }
- subTokens := strings.Fields(fts5SpecialChars.ReplaceAllString(w, " "))
- if len(subTokens) < 2 {
- // Single sub-token after splitting (e.g., N' → N): just use the stripped form
- result = append(result, concat)
- continue
- }
- // Dotted abbreviations (R.E.M., U.K.) — all single letters separated by dots only
- if isDottedAbbreviation(w, subTokens) {
- phrases = append(phrases, fmt.Sprintf(`"%s"`, strings.Join(subTokens, " ")))
- } else {
- // Punctuated names (a-ha, AC/DC, Jay-Z) — phrase for adjacency + concat for search_normalized
- phrases = append(phrases, fmt.Sprintf(`("%s" OR %s*)`, strings.Join(subTokens, " "), concat))
- }
- result = append(result, fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1))
- }
- return strings.Join(result, " "), phrases
-}
-
-// isDottedAbbreviation returns true if w uses only dots as punctuation and all sub-tokens
-// are single letters (e.g., "R.E.M.", "U.K." but not "a-ha" or "AC/DC").
-func isDottedAbbreviation(w string, subTokens []string) bool {
- for _, r := range w {
- if !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '.' {
- return false
- }
- }
- for _, st := range subTokens {
- if !isSingleUnicodeLetter(st) {
- return false
- }
- }
- return true
-}
-
-// 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) {
- q := strings.TrimSpace(userInput)
- if q == "" || q == `""` {
- return "", false
- }
-
- var phrases []string
- result := q
- for {
- start := strings.Index(result, `"`)
- if start == -1 {
- break
- }
- end := strings.Index(result[start+1:], `"`)
- if end == -1 {
- // Unmatched quote — remove it
- result = result[:start] + result[start+1:]
- break
- }
- end += start + 1
- phrase := result[start : end+1] // includes quotes
- phrases = append(phrases, phrase)
- result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:]
- }
-
- // Transliterate non-ASCII letters in the unquoted portion (ø→o, æ→ae, œ→oe, ß→ss, …)
- // so the query matches the ASCII variants emitted by normalizeForFTS at index time.
- // FTS5's own `remove_diacritics 2` only strips NFKD-decomposable marks, so without
- // this step queries for words containing these letters can miss. Quoted phrases are
- // left untouched so they continue to match the original text in title/artist columns.
- result = sanitize.Accents(result)
-
- // Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive:
- // AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens)
- result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower)
-
- // Handle words with embedded punctuation (a-ha, AC/DC, R.E.M.) before stripping
- result, phrases = processPunctuatedWords(result, phrases)
-
- result = fts5SpecialChars.ReplaceAllString(result, " ")
- 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))
- 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 + "*)"
- }
-
- // 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 ")
-
- 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)
-}
-
-// ftsColumn pairs an FTS5 column name with its BM25 relevance weight.
-type ftsColumn struct {
- Name string
- Weight float64
-}
-
-// ftsColumnDefs defines FTS5 columns and their BM25 relevance weights.
-// The order MUST match the column order in the FTS5 table definition (see migrations).
-// All columns are both searched and ranked. When adding indexed-but-not-searched
-// columns in the future, use Weight: 0 to exclude from the search column filter.
-var ftsColumnDefs = map[string][]ftsColumn{
- "media_file": {
- {"title", 10.0},
- {"album", 5.0},
- {"artist", 3.0},
- {"album_artist", 3.0},
- {"sort_title", 1.0},
- {"sort_album_name", 1.0},
- {"sort_artist_name", 1.0},
- {"sort_album_artist_name", 1.0},
- {"disc_subtitle", 1.0},
- {"search_participants", 2.0},
- {"search_normalized", 1.0},
- },
- "album": {
- {"name", 10.0},
- {"sort_album_name", 1.0},
- {"album_artist", 3.0},
- {"search_participants", 2.0},
- {"discs", 1.0},
- {"catalog_num", 1.0},
- {"album_version", 1.0},
- {"search_normalized", 1.0},
- },
- "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},
- },
-}
-
-// ftsColumnFilters and ftsBM25Weights are precomputed from ftsColumnDefs at init time
-// to avoid per-query allocations.
-var (
- ftsColumnFilters = map[string]string{}
- ftsBM25Weights = map[string]string{}
-)
-
-func init() {
- for table, cols := range ftsColumnDefs {
- var names []string
- weights := make([]string, len(cols))
- for i, c := range cols {
- if c.Weight > 0 {
- names = append(names, c.Name)
- }
- weights[i] = fmt.Sprintf("%.1f", c.Weight)
- }
- ftsColumnFilters[table] = "{" + strings.Join(names, " ") + "}"
- ftsBM25Weights[table] = strings.Join(weights, ", ")
- }
-}
-
-// ftsSearch implements searchStrategy using FTS5 full-text search with BM25 ranking.
-type ftsSearch struct {
- tableName string
- ftsTable string
- matchExpr string
- rankExpr string
-}
-
-// ToSql returns a single-query fallback for the REST filter path (no two-phase split).
-func (s *ftsSearch) ToSql() (string, []any, error) {
- sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)"
- return sql, []any{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 {
- qualifiedOrderBys := []string{s.rankExpr}
- for _, ob := range cfg.OrderBy {
- if qualified := qualifyOrderBy(s.tableName, ob); qualified != "" {
- qualifiedOrderBys = append(qualifiedOrderBys, qualified)
- }
- }
-
- rowidCore := Select(s.tableName+".rowid").
- From(s.tableName).
- Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr).
- OrderBy(qualifiedOrderBys...)
- return r.executeTwoPhase(ctx, sq, dest, rowidCore, cfg, options)
-}
-
-// qualifyOrderBy prepends tableName to a simple column name. Returns empty string for
-// complex expressions (function calls, aggregations) that can't be used in Phase 1.
-func qualifyOrderBy(tableName, orderBy string) string {
- orderBy = strings.TrimSpace(orderBy)
- if orderBy == "" || strings.ContainsAny(orderBy, "(,") {
- return ""
- }
- parts := strings.Fields(orderBy)
- if !strings.Contains(parts[0], ".") {
- parts[0] = tableName + "." + parts[0]
- }
- return strings.Join(parts, " ")
-}
-
-// ftsQueryDegraded returns true when the FTS query lost significant discriminating
-// content compared to the original input. This happens when special characters that
-// are part of the entity name (e.g., "1+", "C++", "!!!", "C#") get stripped by FTS
-// tokenization, leaving only very short/broad tokens. Also detects quoted phrases
-// that would be degraded by FTS5's unicode61 tokenizer (e.g., "1+" → token "1").
-func ftsQueryDegraded(original, ftsQuery string) bool {
- original = strings.TrimSpace(original)
- if original == "" || ftsQuery == "" {
- return false
- }
- // 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, "")
- // If the original is entirely alphanumeric, nothing was stripped — not degraded
- if len(alphaNum) == len(stripped) {
- return false
- }
- // 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 {
- t = strings.TrimSuffix(t, "*")
- // Skip internal phrase placeholders
- if strings.HasPrefix(t, "\x00") {
- return false
- }
- // For OR groups from processPunctuatedWords (e.g., ("a ha" OR aha*)),
- // the punctuated word was already handled meaningfully — not degraded.
- if strings.HasPrefix(t, "(") {
- return false
- }
- // For quoted phrases, check the tokens inside as FTS5 will tokenize them
- if strings.HasPrefix(t, `"`) {
- // Extract content between quotes
- inner := strings.Trim(t, `"`)
- innerAlpha := str.FTSPunctStrip.ReplaceAllString(inner, " ")
- for it := range strings.FieldsSeq(innerAlpha) {
- if len(it) > 2 {
- return false
- }
- }
- continue
- }
- if len(t) > 2 {
- return false
- }
- }
- return true
-}
-
-// newFTSSearch creates an FTS5 search strategy. Falls back to LIKE search if the
-// query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!") or if FTS
-// 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 {
- // Fallback: try LIKE search with the raw query
- cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, ""))
- if cleaned != "" {
- log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", cleaned)
- return newLikeSearch(tableName, cleaned)
- }
- return nil
- }
- ftsTable := tableName + "_fts"
- matchExpr := q
- if cols, ok := ftsColumnFilters[tableName]; ok {
- matchExpr = cols + " : (" + q + ")"
- }
-
- rankExpr := ftsTable + ".rank"
- if weights, ok := ftsBM25Weights[tableName]; ok {
- rankExpr = "bm25(" + ftsTable + ", " + weights + ")"
- }
-
- s := &ftsSearch{
- tableName: tableName,
- ftsTable: ftsTable,
- matchExpr: matchExpr,
- rankExpr: rankExpr,
- }
- log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", s)
- return s
-}
diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go
deleted file mode 100644
index 5d52cb1e8..000000000
--- a/persistence/sql_search_fts_test.go
+++ /dev/null
@@ -1,477 +0,0 @@
-package persistence
-
-import (
- "context"
-
- "github.com/navidrome/navidrome/log"
- "github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = DescribeTable("buildFTS5Query",
- func(input, expected string) {
- q, _ := buildFTS5Query(input)
- Expect(q).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("strips standalone *", "*", ""),
- Entry("strips apostrophe from input", "Guns N' Roses", "(Guns OR Guns*) AND (N OR N*) AND (Roses OR Roses*)"),
- Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`),
- Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`),
- Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`),
- Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`),
- Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`),
- Entry("handles punctuated word mixed with plain words", "best of a-ha", `(best OR best*) AND (of OR of*) AND ("a ha" OR aha*)`),
- Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND (got OR got*)`),
- Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "(rock OR rock*) AND (roll OR roll*) AND (vol OR vol*) AND (2 OR 2*)"),
- Entry("transliterates NFKD-decomposable diacritics", "Björk début", "(Bjork OR Bjork*) AND (debut OR debut*)"),
- Entry("transliterates ø to o", "Øystein", "(Oystein OR Oystein*)"),
- Entry("transliterates œ ligature to oe", "œuvre", "(oeuvre OR oeuvre*)"),
- Entry("transliterates æ ligature to ae", "Brennæ", "(Brennae OR Brennae*)"),
- Entry("transliterates mixed unicode words", "Mø Sigur Rós", "(Mo OR Mo*) AND (Sigur OR Sigur*) AND (Ros OR Ros*)"),
- Entry("transliterates ß to ss", "Straße", "(Strasse OR Strasse*)"),
- Entry("preserves quoted unicode phrase verbatim", `"Björk"`, `"Björk"`),
- Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`),
- Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`),
- Entry("collapses abbreviation mixed with words", "best of R.E.M.", `(best OR best*) AND (of OR of*) AND "R E M"`),
- Entry("collapses two-letter abbreviation", "U.K.", `"U K"`),
- Entry("does not collapse single letter surrounded by words", "I am fine", "(I OR I*) AND (am OR am*) AND (fine OR fine*)"),
- Entry("does not collapse single standalone letter", "A test", "(A OR A*) AND (test OR test*)"),
- Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`),
- Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`),
- Entry("returns empty string for punctuation-only input", "!!!!!!!", ""),
- Entry("returns empty string for mixed punctuation", "!@#$%^&", ""),
- 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))
- },
- Entry("not degraded for empty original", "", "1*", false),
- Entry("not degraded for empty ftsQuery", "1+", "", false),
- Entry("not degraded for purely alphanumeric query", "beatles", "beatles*", false),
- Entry("not degraded when long tokens remain", "test^val", "test* val*", false),
- Entry("not degraded for quoted phrase with long tokens", `"the beatles"`, `"the beatles"`, false),
- Entry("degraded for quoted phrase with only short tokens after tokenizer strips special chars", `"1+"`, `"1+"`, true),
- Entry("not degraded for quoted phrase with meaningful content", `"C++ programming"`, `"C++ programming"`, false),
- Entry("degraded when special chars stripped leaving short token", "1+", "1*", true),
- Entry("degraded when special chars stripped leaving two short tokens", "C# 1", "C* 1*", true),
- Entry("not degraded when at least one long token remains", "1+ beatles", "1* beatles*", false),
- Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false),
-)
-
-var _ = DescribeTable("containsCJK",
- func(input string, expected bool) {
- Expect(containsCJK(input)).To(Equal(expected))
- },
- Entry("returns false for empty string", "", false),
- Entry("returns false for ASCII text", "hello world", false),
- Entry("returns false for Latin with diacritics", "Björk début", false),
- Entry("detects Chinese characters (Han)", "周杰伦", true),
- Entry("detects Japanese Hiragana", "こんにちは", true),
- Entry("detects Japanese Katakana", "カタカナ", true),
- Entry("detects Korean Hangul", "한국어", true),
- Entry("detects CJK mixed with Latin", "best of 周杰伦", true),
- Entry("detects single CJK character", "a曲b", true),
-)
-
-var _ = DescribeTable("qualifyOrderBy",
- func(tableName, orderBy, expected string) {
- Expect(qualifyOrderBy(tableName, orderBy)).To(Equal(expected))
- },
- Entry("returns empty string for empty input", "artist", "", ""),
- Entry("qualifies simple column with table name", "artist", "name", "artist.name"),
- Entry("qualifies column with direction", "artist", "name desc", "artist.name desc"),
- Entry("preserves already-qualified column", "artist", "artist.name", "artist.name"),
- Entry("preserves already-qualified column with direction", "artist", "artist.name desc", "artist.name desc"),
- Entry("returns empty for function call expression", "artist", "sum(json_extract(stats, '$.total.m')) desc", ""),
- Entry("returns empty for expression with comma", "artist", "a, b", ""),
- Entry("qualifies media_file column", "media_file", "title", "media_file.title"),
-)
-
-var _ = Describe("ftsColumnDefs helpers", func() {
- Describe("ftsColumnFilters", func() {
- It("returns column filter for media_file", func() {
- Expect(ftsColumnFilters).To(HaveKeyWithValue("media_file",
- "{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}",
- ))
- })
-
- It("returns column filter for album", func() {
- Expect(ftsColumnFilters).To(HaveKeyWithValue("album",
- "{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}",
- ))
- })
-
- It("returns column filter for artist", func() {
- Expect(ftsColumnFilters).To(HaveKeyWithValue("artist",
- "{name sort_artist_name search_normalized}",
- ))
- })
-
- It("has no entry for unknown table", func() {
- Expect(ftsColumnFilters).ToNot(HaveKey("unknown"))
- })
- })
-
- Describe("ftsBM25Weights", func() {
- It("returns weight CSV for media_file", func() {
- Expect(ftsBM25Weights).To(HaveKeyWithValue("media_file",
- "10.0, 5.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0",
- ))
- })
-
- It("returns weight CSV for album", func() {
- Expect(ftsBM25Weights).To(HaveKeyWithValue("album",
- "10.0, 1.0, 3.0, 2.0, 1.0, 1.0, 1.0, 1.0",
- ))
- })
-
- It("returns weight CSV for artist", func() {
- Expect(ftsBM25Weights).To(HaveKeyWithValue("artist",
- "10.0, 1.0, 10.0",
- ))
- })
-
- It("has no entry for unknown table", func() {
- Expect(ftsBM25Weights).ToNot(HaveKey("unknown"))
- })
- })
-
- It("has definitions for all known tables", func() {
- for _, table := range []string{"media_file", "album", "artist"} {
- Expect(ftsColumnDefs).To(HaveKey(table))
- Expect(ftsColumnDefs[table]).ToNot(BeEmpty())
- }
- })
-
- It("has matching column count between filter and weights", func() {
- for table, cols := range ftsColumnDefs {
- // Column filter only includes Weight > 0 columns
- filterCount := 0
- for _, c := range cols {
- if c.Weight > 0 {
- filterCount++
- }
- }
- // For now, all columns have Weight > 0, so filter count == total count
- Expect(filterCount).To(Equal(len(cols)), "table %s: all columns should have positive weights", table)
- }
- })
-})
-
-var _ = Describe("newFTSSearch", func() {
- It("returns nil for empty query", func() {
- Expect(newFTSSearch("media_file", "")).To(BeNil())
- })
-
- It("returns non-nil for single-character query", func() {
- strategy := newFTSSearch("media_file", "a")
- Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
- sql, _, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("MATCH"))
- })
-
- It("returns ftsSearch with correct table names and MATCH expression", func() {
- strategy := newFTSSearch("media_file", "beatles")
- fts, ok := strategy.(*ftsSearch)
- Expect(ok).To(BeTrue())
- Expect(fts.tableName).To(Equal("media_file"))
- Expect(fts.ftsTable).To(Equal("media_file_fts"))
- Expect(fts.matchExpr).To(HavePrefix("{title album artist album_artist"))
- Expect(fts.matchExpr).To(ContainSubstring("beatles*"))
- })
-
- It("ToSql generates rowid IN subquery with MATCH (fallback path)", func() {
- strategy := newFTSSearch("media_file", "beatles")
- sql, args, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("media_file.rowid IN"))
- Expect(sql).To(ContainSubstring("media_file_fts"))
- Expect(sql).To(ContainSubstring("MATCH"))
- Expect(args).To(HaveLen(1))
- })
-
- It("generates correct FTS table name per entity", func() {
- for _, table := range []string{"media_file", "album", "artist"} {
- strategy := newFTSSearch(table, "test")
- fts, ok := strategy.(*ftsSearch)
- Expect(ok).To(BeTrue())
- Expect(fts.tableName).To(Equal(table))
- Expect(fts.ftsTable).To(Equal(table + "_fts"))
- }
- })
-
- It("builds bm25() rank expression with column weights", func() {
- strategy := newFTSSearch("media_file", "beatles")
- fts, ok := strategy.(*ftsSearch)
- Expect(ok).To(BeTrue())
- Expect(fts.rankExpr).To(HavePrefix("bm25(media_file_fts,"))
- Expect(fts.rankExpr).To(ContainSubstring("10.0"))
-
- strategy = newFTSSearch("artist", "beatles")
- fts, ok = strategy.(*ftsSearch)
- Expect(ok).To(BeTrue())
- Expect(fts.rankExpr).To(HavePrefix("bm25(artist_fts,"))
- })
-
- It("falls back to ftsTable.rank for unknown tables", func() {
- strategy := newFTSSearch("unknown_table", "test")
- fts, ok := strategy.(*ftsSearch)
- Expect(ok).To(BeTrue())
- Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank"))
- })
-
- It("wraps query with column filter", func() {
- strategy := newFTSSearch("artist", "Beatles")
- fts, ok := strategy.(*ftsSearch)
- Expect(ok).To(BeTrue())
- Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : ((Beatles OR Beatles*))"))
- })
-
- It("passes query without column filter for unknown tables", func() {
- strategy := newFTSSearch("unknown_table", "test")
- fts, ok := strategy.(*ftsSearch)
- Expect(ok).To(BeTrue())
- Expect(fts.matchExpr).To(Equal("(test OR test*)"))
- })
-
- It("preserves phrase queries inside column filter", func() {
- strategy := newFTSSearch("media_file", `"the beatles"`)
- fts, ok := strategy.(*ftsSearch)
- Expect(ok).To(BeTrue())
- Expect(fts.matchExpr).To(ContainSubstring(`"the beatles"`))
- })
-
- It("preserves prefix queries inside column filter", func() {
- strategy := newFTSSearch("media_file", "beat*")
- fts, ok := strategy.(*ftsSearch)
- Expect(ok).To(BeTrue())
- Expect(fts.matchExpr).To(ContainSubstring("beat*"))
- })
-
- It("falls back to LIKE search for punctuation-only query", func() {
- strategy := newFTSSearch("media_file", "!!!!!!!")
- Expect(strategy).ToNot(BeNil())
- _, ok := strategy.(*ftsSearch)
- Expect(ok).To(BeFalse(), "punctuation-only should fall back to LIKE, not FTS")
- sql, args, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("LIKE"))
- Expect(args).To(ContainElement("%!!!!!!!%"))
- })
-
- It("falls back to LIKE search for degraded query (special chars stripped leaving short tokens)", func() {
- strategy := newFTSSearch("album", "1+")
- Expect(strategy).ToNot(BeNil())
- _, ok := strategy.(*ftsSearch)
- Expect(ok).To(BeFalse(), "degraded query should fall back to LIKE, not FTS")
- sql, args, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("LIKE"))
- Expect(args).To(ContainElement("%1+%"))
- })
-
- It("returns nil for empty string even with LIKE fallback", func() {
- Expect(newFTSSearch("media_file", "")).To(BeNil())
- Expect(newFTSSearch("media_file", " ")).To(BeNil())
- })
-
- It("returns nil for empty quoted phrase", func() {
- Expect(newFTSSearch("media_file", `""`)).To(BeNil())
- })
-})
-
-var _ = Describe("FTS5 Integration Search", func() {
- var (
- mr model.MediaFileRepository
- alr model.AlbumRepository
- arr model.ArtistRepository
- ctx context.Context
- )
-
- BeforeEach(func() {
- ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
- conn := GetDBXBuilder()
- mr = NewMediaFileRepository(conn)
- alr = NewAlbumRepository(conn)
- arr = NewArtistRepository(conn)
- })
-
- Describe("MediaFile search", func() {
- It("finds media files by title", func() {
- results, err := mr.Search(ctx, "Radioactivity", model.QueryOptions{Max: 10})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(1))
- Expect(results[0].Title).To(Equal("Radioactivity"))
- Expect(results[0].ID).To(Equal(songRadioactivity.ID))
- })
-
- It("finds media files by artist name", func() {
- results, err := mr.Search(ctx, "Beatles", model.QueryOptions{Max: 10})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(3))
- for _, r := range results {
- Expect(r.Artist).To(Equal("The Beatles"))
- }
- })
- })
-
- Describe("Album search", func() {
- It("finds albums by name", func() {
- results, err := alr.Search(ctx, "Sgt Peppers", model.QueryOptions{Max: 10})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(1))
- Expect(results[0].Name).To(Equal("Sgt Peppers"))
- Expect(results[0].ID).To(Equal(albumSgtPeppers.ID))
- })
-
- It("finds albums with multi-word search", func() {
- results, err := alr.Search(ctx, "Abbey Road", model.QueryOptions{Max: 10})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(2))
- })
- })
-
- Describe("Artist search", func() {
- It("finds artists by name", func() {
- results, err := arr.Search(ctx, "Kraftwerk", model.QueryOptions{Max: 10})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(1))
- Expect(results[0].Name).To(Equal("Kraftwerk"))
- Expect(results[0].ID).To(Equal(artistKraftwerk.ID))
- })
- })
-
- Describe("CJK search", func() {
- It("finds media files by CJK title", func() {
- results, err := mr.Search(ctx, "プラチナ", model.QueryOptions{Max: 10})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(1))
- Expect(results[0].Title).To(Equal("プラチナ・ジェット"))
- Expect(results[0].ID).To(Equal(songCJK.ID))
- })
-
- It("finds media files by CJK artist name", func() {
- results, err := mr.Search(ctx, "シートベルツ", 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})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(1))
- Expect(results[0].Name).To(Equal("COWBOY BEBOP"))
- Expect(results[0].ID).To(Equal(albumCJK.ID))
- })
-
- It("finds artists by CJK name", func() {
- results, err := arr.Search(ctx, "シートベルツ", model.QueryOptions{Max: 10})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(1))
- Expect(results[0].Name).To(Equal("シートベルツ"))
- Expect(results[0].ID).To(Equal(artistCJK.ID))
- })
- })
-
- Describe("Album version search", func() {
- It("finds albums by version tag via FTS", func() {
- results, err := alr.Search(ctx, "Deluxe", model.QueryOptions{Max: 10})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(1))
- Expect(results[0].ID).To(Equal(albumWithVersion.ID))
- })
- })
-
- Describe("Punctuation-only search", func() {
- It("finds media files with punctuation-only title", func() {
- results, err := mr.Search(ctx, "!!!!!!!", model.QueryOptions{Max: 10})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(HaveLen(1))
- Expect(results[0].Title).To(Equal("!!!!!!!"))
- Expect(results[0].ID).To(Equal(songPunctuation.ID))
- })
- })
-
- 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})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).To(BeEmpty(), "doSearch should reject single-char queries")
- })
- })
-
- 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})
- 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
deleted file mode 100644
index c7d94cc04..000000000
--- a/persistence/sql_search_like.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package persistence
-
-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/utils/str"
-)
-
-// likeSearch implements searchStrategy using LIKE-based SQL filters.
-// Used for legacy full_text searches, CJK fallback, and punctuation-only fallback.
-type likeSearch struct {
- filter Sqlizer
-}
-
-func (s *likeSearch) ToSql() (string, []any, error) {
- return s.filter.ToSql()
-}
-
-func (s *likeSearch) execute(ctx context.Context, 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)
-}
-
-// newLegacySearch creates a LIKE search against the full_text column.
-// Returns nil when the query produces no searchable tokens.
-func newLegacySearch(tableName, query string) searchStrategy {
- filter := legacySearchExpr(tableName, query)
- if filter == nil {
- return nil
- }
- return &likeSearch{filter: filter}
-}
-
-// newLikeSearch creates a LIKE search against core entity columns (CJK, punctuation fallback).
-// No minimum length is enforced, since single CJK characters are meaningful words.
-// Returns nil when the query produces no searchable tokens.
-func newLikeSearch(tableName, query string) searchStrategy {
- filter := likeSearchExpr(tableName, query)
- if filter == nil {
- return nil
- }
- return &likeSearch{filter: filter}
-}
-
-// legacySearchExpr generates LIKE-based search filters against the full_text column.
-// This is the original search implementation, used when Search.Backend="legacy".
-func legacySearchExpr(tableName string, s string) Sqlizer {
- q := str.SanitizeStrings(s)
- if q == "" {
- log.Trace("Search using legacy backend, query is empty", "table", tableName)
- return nil
- }
- var sep string
- if !conf.Server.Search.FullString {
- sep = " "
- }
- parts := strings.Split(q, " ")
- filters := And{}
- for _, part := range parts {
- filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
- }
- log.Trace("Search using legacy backend", "query", filters, "table", tableName)
- return filters
-}
-
-// likeSearchColumns defines the core columns to search with LIKE queries.
-// These are the primary user-visible fields for each entity type.
-// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
-var likeSearchColumns = map[string][]string{
- "media_file": {"title", "album", "artist", "album_artist"},
- "album": {"name", "album_artist"},
- "artist": {"name"},
-}
-
-// likeSearchExpr generates LIKE-based search filters against core columns.
-// Each word in the query must match at least one column (AND between words),
-// and each word can match any column (OR within a word).
-// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
-func likeSearchExpr(tableName string, s string) Sqlizer {
- s = strings.TrimSpace(s)
- if s == "" {
- log.Trace("Search using LIKE backend, query is empty", "table", tableName)
- return nil
- }
- columns, ok := likeSearchColumns[tableName]
- if !ok {
- log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName)
- return nil
- }
- words := strings.Fields(s)
- wordFilters := And{}
- for _, word := range words {
- colFilters := Or{}
- for _, col := range columns {
- colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"})
- }
- wordFilters = append(wordFilters, colFilters)
- }
- log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName)
- return wordFilters
-}
diff --git a/persistence/sql_search_like_test.go b/persistence/sql_search_like_test.go
deleted file mode 100644
index 2bfeba29b..000000000
--- a/persistence/sql_search_like_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-package persistence
-
-import (
- "context"
-
- "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/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("newLegacySearch", func() {
- It("returns non-nil for single-character query", func() {
- strategy := newLegacySearch("media_file", "a")
- Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here")
- sql, _, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("LIKE"))
- })
-})
-
-var _ = Describe("legacySearchExpr", func() {
- It("returns nil for empty query", func() {
- Expect(legacySearchExpr("media_file", "")).To(BeNil())
- })
-
- It("generates LIKE filter for single word", func() {
- expr := legacySearchExpr("media_file", "beatles")
- sql, args, err := expr.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("media_file.full_text LIKE"))
- Expect(args).To(ContainElement("% beatles%"))
- })
-
- It("generates AND of LIKE filters for multiple words", func() {
- expr := legacySearchExpr("media_file", "abbey road")
- sql, args, err := expr.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("AND"))
- Expect(args).To(HaveLen(2))
- })
-})
-
-var _ = Describe("likeSearchExpr", func() {
- It("returns nil for empty query", func() {
- Expect(likeSearchExpr("media_file", "")).To(BeNil())
- })
-
- It("returns nil for whitespace-only query", func() {
- Expect(likeSearchExpr("media_file", " ")).To(BeNil())
- })
-
- It("generates LIKE filters against core columns for single CJK word", func() {
- expr := likeSearchExpr("media_file", "周杰伦")
- sql, args, err := expr.ToSql()
- Expect(err).ToNot(HaveOccurred())
- // Should have OR between columns for the single word
- Expect(sql).To(ContainSubstring("OR"))
- Expect(sql).To(ContainSubstring("media_file.title LIKE"))
- Expect(sql).To(ContainSubstring("media_file.album LIKE"))
- Expect(sql).To(ContainSubstring("media_file.artist LIKE"))
- Expect(sql).To(ContainSubstring("media_file.album_artist LIKE"))
- Expect(args).To(HaveLen(4))
- for _, arg := range args {
- Expect(arg).To(Equal("%周杰伦%"))
- }
- })
-
- It("generates AND of OR groups for multi-word query", func() {
- expr := likeSearchExpr("media_file", "周杰伦 greatest")
- sql, args, err := expr.ToSql()
- Expect(err).ToNot(HaveOccurred())
- // Two groups AND'd together, each with 4 columns OR'd
- Expect(sql).To(ContainSubstring("AND"))
- Expect(args).To(HaveLen(8))
- })
-
- It("uses correct columns for album table", func() {
- expr := likeSearchExpr("album", "周杰伦")
- sql, args, err := expr.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("album.name LIKE"))
- Expect(sql).To(ContainSubstring("album.album_artist LIKE"))
- Expect(args).To(HaveLen(2))
- })
-
- It("uses correct columns for artist table", func() {
- expr := likeSearchExpr("artist", "周杰伦")
- sql, args, err := expr.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("artist.name LIKE"))
- Expect(args).To(HaveLen(1))
- })
-
- It("returns nil for unknown table", func() {
- Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil())
- })
-})
-
-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"
-
- conn := GetDBXBuilder()
- mr = NewMediaFileRepository(conn)
- })
-
- It("returns results using legacy LIKE-based search", func() {
- results, err := mr.Search(ctx, "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})
- 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})
- Expect(err).ToNot(HaveOccurred())
- Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
- })
-})
diff --git a/persistence/sql_search_test.go b/persistence/sql_search_test.go
index 68ee205b4..6bfd88d9f 100644
--- a/persistence/sql_search_test.go
+++ b/persistence/sql_search_test.go
@@ -1,8 +1,6 @@
package persistence
import (
- "github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -13,100 +11,4 @@ var _ = Describe("sqlRepository", func() {
Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana"))
})
})
-
- Describe("getSearchStrategy", func() {
- It("returns FTS strategy by default", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.Search.Backend = "fts"
- conf.Server.Search.FullString = false
-
- strategy := getSearchStrategy("media_file", "test")
- Expect(strategy).ToNot(BeNil())
- sql, _, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("MATCH"))
- })
-
- It("returns legacy LIKE strategy when SearchBackend is legacy", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.Search.Backend = "legacy"
- conf.Server.Search.FullString = false
-
- strategy := getSearchStrategy("media_file", "test")
- Expect(strategy).ToNot(BeNil())
- sql, _, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("LIKE"))
- })
-
- It("falls back to legacy LIKE strategy when SearchFullString is enabled", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.Search.Backend = "fts"
- conf.Server.Search.FullString = true
-
- strategy := getSearchStrategy("media_file", "test")
- Expect(strategy).ToNot(BeNil())
- sql, _, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("LIKE"))
- })
-
- It("routes CJK queries to LIKE strategy instead of FTS", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.Search.Backend = "fts"
- conf.Server.Search.FullString = false
-
- strategy := getSearchStrategy("media_file", "周杰伦")
- Expect(strategy).ToNot(BeNil())
- sql, _, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- // CJK should use LIKE, not MATCH
- Expect(sql).To(ContainSubstring("LIKE"))
- Expect(sql).NotTo(ContainSubstring("MATCH"))
- })
-
- It("routes non-CJK queries to FTS strategy", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.Search.Backend = "fts"
- conf.Server.Search.FullString = false
-
- strategy := getSearchStrategy("media_file", "beatles")
- Expect(strategy).ToNot(BeNil())
- sql, _, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- Expect(sql).To(ContainSubstring("MATCH"))
- })
-
- It("returns non-nil for single-character query (no min-length in strategy)", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.Search.Backend = "fts"
- conf.Server.Search.FullString = false
-
- strategy := getSearchStrategy("media_file", "a")
- Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by strategies (min-length is enforced in doSearch)")
- })
-
- It("returns non-nil for single-character query with legacy backend", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.Search.Backend = "legacy"
- conf.Server.Search.FullString = false
-
- strategy := getSearchStrategy("media_file", "a")
- Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by legacy strategy (min-length is enforced in doSearch)")
- })
-
- It("uses legacy for CJK when SearchBackend is legacy", func() {
- DeferCleanup(configtest.SetupConfig())
- conf.Server.Search.Backend = "legacy"
- conf.Server.Search.FullString = false
-
- strategy := getSearchStrategy("media_file", "周杰伦")
- Expect(strategy).ToNot(BeNil())
- sql, _, err := strategy.ToSql()
- Expect(err).ToNot(HaveOccurred())
- // Legacy should still use full_text column LIKE
- Expect(sql).To(ContainSubstring("LIKE"))
- Expect(sql).To(ContainSubstring("full_text"))
- })
- })
})
diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go
index 7e1978453..8c3c1e89d 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(
@@ -132,7 +60,7 @@ func tagIDFilter(name string, idValue any) Sqlizer {
}
// tagLibraryIdFilter filters tags based on library access through the library_tag table
-func tagLibraryIdFilter(_ string, value any) Sqlizer {
+func tagLibraryIdFilter(_ string, value interface{}) Sqlizer {
return Eq{"library_tag.library_id": value}
}
@@ -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) (interface{}, 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) (interface{}, 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() interface{} {
+ 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..ab0d57d52 100644
--- a/persistence/tag_library_filtering_test.go
+++ b/persistence/tag_library_filtering_test.go
@@ -2,7 +2,6 @@ package persistence
import (
"context"
- "time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf/configtest"
@@ -46,9 +45,6 @@ var _ = Describe("Tag Library Filtering", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- // Generate unique path suffix to avoid conflicts with other tests
- uniqueSuffix := time.Now().Format("20060102150405.000")
-
// Clean up database
db := GetDBXBuilder()
_, err := db.NewQuery("DELETE FROM library_tag").Execute()
@@ -61,12 +57,12 @@ var _ = Describe("Tag Library Filtering", func() {
_, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute()
Expect(err).ToNot(HaveOccurred())
- // Create test libraries with unique names and paths to avoid conflicts with other tests
+ // Create test libraries
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
- Bind(dbx.Params{"id": libraryID2, "name": "Library 2-" + uniqueSuffix, "path": "/music/lib2-" + uniqueSuffix}).Execute()
+ Bind(dbx.Params{"id": libraryID2, "name": "Library 2", "path": "/music/lib2"}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
- Bind(dbx.Params{"id": libraryID3, "name": "Library 3-" + uniqueSuffix, "path": "/music/lib3-" + uniqueSuffix}).Execute()
+ Bind(dbx.Params{"id": libraryID3, "name": "Library 3", "path": "/music/lib3"}).Execute()
Expect(err).ToNot(HaveOccurred())
// Give admin access to all libraries
@@ -78,11 +74,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 +115,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 +137,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
}
@@ -163,7 +161,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should respect explicit library_id filters within accessible libraries", func() {
tags := readAllTags(®ularUser, rest.QueryOptions{
- Filters: map[string]any{"library_id": libraryID2},
+ Filters: map[string]interface{}{"library_id": libraryID2},
})
// Should see only tags from library 2: pop and rock(lib2)
Expect(tags).To(HaveLen(2))
@@ -172,7 +170,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should not return tags when filtering by inaccessible library", func() {
tags := readAllTags(®ularUser, rest.QueryOptions{
- Filters: map[string]any{"library_id": libraryID3},
+ Filters: map[string]interface{}{"library_id": libraryID3},
})
// Should return no tags since user can't access library 3
Expect(tags).To(HaveLen(0))
@@ -180,7 +178,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should filter by library 1 correctly", func() {
tags := readAllTags(®ularUser, rest.QueryOptions{
- Filters: map[string]any{"library_id": libraryID1},
+ Filters: map[string]interface{}{"library_id": libraryID1},
})
// Should see only rock from library 1
Expect(tags).To(HaveLen(1))
@@ -225,7 +223,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should allow headless processes to apply explicit library_id filters", func() {
tags := readAllTags(nil, rest.QueryOptions{
- Filters: map[string]any{"library_id": libraryID3},
+ Filters: map[string]interface{}{"library_id": libraryID3},
})
// Should see only jazz from library 3
Expect(tags).To(HaveLen(1))
@@ -241,7 +239,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should respect explicit library_id filters", func() {
tags := readAllTags(&adminUser, rest.QueryOptions{
- Filters: map[string]any{"library_id": libraryID3},
+ Filters: map[string]interface{}{"library_id": libraryID3},
})
// Should see only jazz from library 3
Expect(tags).To(HaveLen(1))
@@ -250,7 +248,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should filter by library 2 correctly", func() {
tags := readAllTags(&adminUser, rest.QueryOptions{
- Filters: map[string]any{"library_id": libraryID2},
+ Filters: map[string]interface{}{"library_id": libraryID2},
})
// Should see pop and rock from library 2
Expect(tags).To(HaveLen(2))
diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go
index d13dece66..b224450ab 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)
+ return fmt.Errorf("error purging unused tags: %w", err)
}
if c > 0 {
- log.Debug(ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName)
+ log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c)
}
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..c3947a9f7 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,32 +210,35 @@ 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))
})
It("should filter tags by partial value correctly", func() {
options := rest.QueryOptions{
- Filters: map[string]any{"name": "%rock%"}, // Tags containing 'rock'
+ Filters: map[string]interface{}{"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
@@ -245,10 +249,11 @@ var _ = Describe("TagRepository", func() {
It("should filter tags by partial value using LIKE", func() {
options := rest.QueryOptions{
- Filters: map[string]any{"name": "%e%"}, // Tags containing 'e'
+ Filters: map[string]interface{}{"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
@@ -259,12 +264,13 @@ var _ = Describe("TagRepository", func() {
It("should sort tags by value ascending", func() {
options := rest.QueryOptions{
- Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
+ Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r'
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 {
@@ -274,12 +280,13 @@ var _ = Describe("TagRepository", func() {
It("should sort tags by value descending", func() {
options := rest.QueryOptions{
- Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
+ Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r'
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..125f57541 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) (interface{}, 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) (interface{}, 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() interface{} {
+ return &model.Transcoding{}
+}
+
+func (r *transcodingRepository) Save(entity interface{}) (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 interface{}, 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..7baa8f6a8 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,47 +331,16 @@ 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
}
- return nil
+ return err
}
func keyTo32Bytes(input string) []byte {
@@ -391,7 +348,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 +357,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 +372,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 +398,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 +407,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 +418,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 +439,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 +447,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 +464,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/.gitignore b/plugins/.gitignore
deleted file mode 100644
index 5026985e6..000000000
--- a/plugins/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# Rust build artifacts
-# Cargo.lock is not needed for library crates (this is a cdylib)
-Cargo.lock
-target
\ No newline at end of file
diff --git a/plugins/README.md b/plugins/README.md
index 7dca3a5f2..100230cbf 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -1,1321 +1,1760 @@
# Navidrome Plugin System
-Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access.
+## Overview
-The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs).
+Navidrome's plugin system is a WebAssembly (WASM) based extension mechanism that enables developers to expand Navidrome's functionality without modifying the core codebase. The plugin system supports several capabilities that can be implemented by plugins:
-**Essential Extism Resources:**
-- [Extism Documentation](https://extism.org/docs/overview) – Core concepts and architecture
-- [Plugin Development Kits (PDKs)](https://extism.org/docs/concepts/pdk) – Language-specific libraries for writing plugins
-- [Go PDK](https://github.com/extism/go-pdk) – Recommended for Go plugins with TinyGo
-- [Rust PDK](https://github.com/extism/rust-pdk) – For Rust plugins
-- [Python PDK](https://github.com/extism/python-pdk) – Experimental Python support
-- [JavaScript PDK](https://github.com/extism/js-pdk) – For TypeScript/JavaScript plugins
+1. **MetadataAgent** - For fetching artist and album information, images, etc.
+2. **Scrobbler** - For implementing scrobbling functionality with external services
+3. **SchedulerCallback** - For executing code after a specified delay or on a recurring schedule
+4. **WebSocketCallback** - For interacting with WebSocket endpoints and handling WebSocket events
+5. **LifecycleManagement** - For plugin initialization and configuration (one-time `OnInit` only; not invoked per-request)
-## Table of Contents
+## Plugin Architecture
-- [Quick Start](#quick-start)
-- [Plugin Basics](#plugin-basics)
-- [Capabilities](#capabilities)
- - [MetadataAgent](#metadataagent)
- - [Scrobbler](#scrobbler)
- - [Lyrics](#lyrics)
- - [SonicSimilarity](#sonicsimilarity)
- - [TaskWorker](#taskworker)
- - [Lifecycle](#lifecycle)
- - [SchedulerCallback](#schedulercallback)
- - [WebSocketCallback](#websocketcallback)
-- [Host Services](#host-services)
- - [HTTP](#http)
- - [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)
+The plugin system is built on the following key components:
----
+### 1. Plugin Manager
-## Quick Start
+The `Manager` (implemented in `plugins/manager.go`) is the core component that:
-### 1. Create a minimal plugin
+- Scans for plugins in the configured plugins directory
+- Loads and compiles plugins
+- Provides access to loaded plugins through capability-specific interfaces
-Create `main.go`:
+### 2. Plugin Protocol
-```go
-package main
+Plugins communicate with Navidrome using Protocol Buffers (protobuf) over a WASM runtime. The protocol is defined in `plugins/api/api.proto` which specifies the capabilities and messages that plugins can implement.
-import "github.com/extism/go-pdk"
+### 3. Plugin Adapters
-func main() {}
+Adapters bridge between the plugin API and Navidrome's internal interfaces:
-// Implement your capability functions here
-```
+- `wasmMediaAgent` adapts `MetadataAgent` to the internal `agents.Interface`
+- `wasmScrobblerPlugin` adapts `Scrobbler` to the internal `scrobbler.Scrobbler`
+- `wasmSchedulerCallback` adapts `SchedulerCallback` to the internal `SchedulerCallback`
-Create `manifest.json`:
+* **Plugin Instance Pooling**: Instances are managed in an internal pool (default 8 max, 1m TTL).
+* **WASM Compilation & Caching**: Modules are pre-compiled concurrently (max 2) and cached in `[CacheFolder]/plugins`, reducing startup time. The compilation timeout can be configured via `DevPluginCompilationTimeout` in development.
-```json
-{
- "name": "My Plugin",
- "author": "Your Name",
- "version": "1.0.0"
+### 4. Host Services
+
+Navidrome provides host services that plugins can call to access functionality like HTTP requests and scheduling.
+These services are defined in `plugins/host/` and implemented in corresponding host files:
+
+- HTTP service (in `plugins/host_http.go`) for making external requests
+- Scheduler service (in `plugins/host_scheduler.go`) for scheduling timed events
+- Config service (in `plugins/host_config.go`) for accessing plugin-specific configuration
+- WebSocket service (in `plugins/host_websocket.go`) for WebSocket communication
+- Cache service (in `plugins/host_cache.go`) for TTL-based plugin caching
+- Artwork service (in `plugins/host_artwork.go`) for generating public artwork URLs
+- SubsonicAPI service (in `plugins/host_subsonicapi.go`) for accessing Navidrome's Subsonic API
+
+### Available Host Services
+
+The following host services are available to plugins:
+
+#### HttpService
+
+```protobuf
+// HTTP methods available to plugins
+service HttpService {
+ rpc Get(HttpRequest) returns (HttpResponse);
+ rpc Post(HttpRequest) returns (HttpResponse);
+ rpc Put(HttpRequest) returns (HttpResponse);
+ rpc Delete(HttpRequest) returns (HttpResponse);
+ rpc Patch(HttpRequest) returns (HttpResponse);
+ rpc Head(HttpRequest) returns (HttpResponse);
+ rpc Options(HttpRequest) returns (HttpResponse);
}
```
-### 2. Build with TinyGo and package as .ndp
+#### ConfigService
-```bash
-# Compile to WebAssembly
-tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
-
-# Package as .ndp (zip archive)
-zip -j my-plugin.ndp manifest.json plugin.wasm
+```protobuf
+service ConfigService {
+ rpc GetPluginConfig(GetPluginConfigRequest) returns (GetPluginConfigResponse);
+}
```
-### 3. Install
+The ConfigService allows plugins to access plugin-specific configuration. See the [config.proto](host/config/config.proto) file for the full API.
-Copy `my-plugin.ndp` to your Navidrome plugins folder and enable plugins in your config:
+#### ArtworkService
+
+```protobuf
+service ArtworkService {
+ rpc GetArtistUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
+ rpc GetAlbumUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
+ rpc GetTrackUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
+}
+```
+
+Provides methods to get public URLs for artwork images:
+
+- `GetArtistUrl(id string, size int) string`: Returns a public URL for an artist's artwork
+- `GetAlbumUrl(id string, size int) string`: Returns a public URL for an album's artwork
+- `GetTrackUrl(id string, size int) string`: Returns a public URL for a track's artwork
+
+The `size` parameter is optional (use 0 for original size). The URLs returned are based on the server's ShareURL configuration.
+
+Example:
+
+```go
+url := artwork.GetArtistUrl("123", 300) // Get artist artwork URL with size 300px
+url := artwork.GetAlbumUrl("456", 0) // Get album artwork URL in original size
+```
+
+#### CacheService
+
+```protobuf
+service CacheService {
+ // Set a string value in the cache
+ rpc SetString(SetStringRequest) returns (SetResponse);
+
+ // Get a string value from the cache
+ rpc GetString(GetRequest) returns (GetStringResponse);
+
+ // Set an integer value in the cache
+ rpc SetInt(SetIntRequest) returns (SetResponse);
+
+ // Get an integer value from the cache
+ rpc GetInt(GetRequest) returns (GetIntResponse);
+
+ // Set a float value in the cache
+ rpc SetFloat(SetFloatRequest) returns (SetResponse);
+
+ // Get a float value from the cache
+ rpc GetFloat(GetRequest) returns (GetFloatResponse);
+
+ // Set a byte slice value in the cache
+ rpc SetBytes(SetBytesRequest) returns (SetResponse);
+
+ // Get a byte slice value from the cache
+ rpc GetBytes(GetRequest) returns (GetBytesResponse);
+
+ // Remove a value from the cache
+ rpc Remove(RemoveRequest) returns (RemoveResponse);
+
+ // Check if a key exists in the cache
+ rpc Has(HasRequest) returns (HasResponse);
+}
+```
+
+The CacheService provides a TTL-based cache for plugins. Each plugin gets its own isolated cache instance. By default, cached items expire after 24 hours unless a custom TTL is specified.
+
+Key features:
+
+- **Isolated Caches**: Each plugin has its own cache namespace, so different plugins can use the same key names without conflicts
+- **Typed Values**: Store and retrieve values with their proper types (string, int64, float64, or byte slice)
+- **Configurable TTL**: Set custom expiration times per item, or use the default 24-hour TTL
+- **Type Safety**: The system handles type checking, returning "not exists" if there's a type mismatch
+
+Example usage:
+
+```go
+// Store a string value with default TTL (24 hours)
+cacheService.SetString(ctx, &cache.SetStringRequest{
+ Key: "user_preference",
+ Value: "dark_mode",
+})
+
+// Store an integer with custom TTL (5 minutes)
+cacheService.SetInt(ctx, &cache.SetIntRequest{
+ Key: "api_call_count",
+ Value: 42,
+ TtlSeconds: 300, // 5 minutes
+})
+
+// Retrieve a value
+resp, err := cacheService.GetString(ctx, &cache.GetRequest{
+ Key: "user_preference",
+})
+if err != nil {
+ // Handle error
+}
+if resp.Exists {
+ // Use resp.Value
+} else {
+ // Key doesn't exist or has expired
+}
+
+// Check if a key exists
+hasResp, err := cacheService.Has(ctx, &cache.HasRequest{
+ Key: "api_call_count",
+})
+if hasResp.Exists {
+ // Key exists and hasn't expired
+}
+
+// Remove a value
+cacheService.Remove(ctx, &cache.RemoveRequest{
+ Key: "user_preference",
+})
+```
+
+See the [cache.proto](host/cache/cache.proto) file for the full API definition.
+
+#### SchedulerService
+
+The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks, as well as accessing current time information. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API.
+
+```protobuf
+service SchedulerService {
+ // One-time event scheduling
+ rpc ScheduleOneTime(ScheduleOneTimeRequest) returns (ScheduleResponse);
+
+ // Recurring event scheduling
+ rpc ScheduleRecurring(ScheduleRecurringRequest) returns (ScheduleResponse);
+
+ // Cancel any scheduled job
+ rpc CancelSchedule(CancelRequest) returns (CancelResponse);
+
+ // Get current time in multiple formats
+ rpc TimeNow(TimeNowRequest) returns (TimeNowResponse);
+}
+```
+
+**Key Features:**
+
+- **One-time scheduling**: Schedule a callback to be executed once after a specified delay.
+- **Recurring scheduling**: Schedule a callback to be executed repeatedly according to a cron expression.
+- **Current time access**: Get the current time in standardized formats for time-based operations.
+
+**TimeNow Function:**
+
+The `TimeNow` function returns the current time in three formats:
+
+```protobuf
+message TimeNowResponse {
+ string rfc3339_nano = 1; // RFC3339 format with nanosecond precision
+ int64 unix_milli = 2; // Unix timestamp in milliseconds
+ string local_time_zone = 3; // Local timezone name (e.g., "UTC", "America/New_York")
+}
+```
+
+This allows plugins to:
+
+- Get high-precision timestamps for logging and event correlation
+- Perform time-based calculations using Unix timestamps
+- Handle timezone-aware operations by knowing the server's local timezone
+
+Example usage:
+
+```go
+// Get current time information
+timeResp, err := scheduler.TimeNow(ctx, &scheduler.TimeNowRequest{})
+if err != nil {
+ return err
+}
+
+// Use the different time formats
+timestamp := timeResp.Rfc3339Nano // "2024-01-15T10:30:45.123456789Z"
+unixMs := timeResp.UnixMilli // 1705312245123
+timezone := timeResp.LocalTimeZone // "UTC"
+```
+
+Plugins using this service must implement the `SchedulerCallback` interface:
+
+```protobuf
+service SchedulerCallback {
+ rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse);
+}
+```
+
+The `IsRecurring` field in the request allows plugins to differentiate between one-time and recurring callbacks.
+
+#### WebSocketService
+
+The WebSocketService enables plugins to connect to and interact with WebSocket endpoints. See the [websocket.proto](host/websocket/websocket.proto) file for the full API.
+
+```protobuf
+service WebSocketService {
+ // Connect to a WebSocket endpoint
+ rpc Connect(ConnectRequest) returns (ConnectResponse);
+
+ // Send a text message
+ rpc SendText(SendTextRequest) returns (SendTextResponse);
+
+ // Send binary data
+ rpc SendBinary(SendBinaryRequest) returns (SendBinaryResponse);
+
+ // Close a connection
+ rpc Close(CloseRequest) returns (CloseResponse);
+}
+```
+
+- **Connect**: Establish a WebSocket connection to a specified URL with optional headers
+- **SendText**: Send text messages over an established connection
+- **SendBinary**: Send binary data over an established connection
+- **Close**: Close a WebSocket connection with optional close code and reason
+
+Plugins using this service must implement the `WebSocketCallback` interface to handle incoming messages and connection events:
+
+```protobuf
+service WebSocketCallback {
+ rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse);
+ rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse);
+ rpc OnError(OnErrorRequest) returns (OnErrorResponse);
+ rpc OnClose(OnCloseRequest) returns (OnCloseResponse);
+}
+```
+
+Example usage:
+
+```go
+// Connect to a WebSocket server
+connectResp, err := websocket.Connect(ctx, &websocket.ConnectRequest{
+ Url: "wss://example.com/ws",
+ Headers: map[string]string{"Authorization": "Bearer token"},
+ ConnectionId: "my-connection-id",
+})
+if err != nil {
+ return err
+}
+
+// Send a text message
+_, err = websocket.SendText(ctx, &websocket.SendTextRequest{
+ ConnectionId: "my-connection-id",
+ Message: "Hello WebSocket",
+})
+
+// Send binary data
+_, err = websocket.SendBinary(ctx, &websocket.SendBinaryRequest{
+ ConnectionId: "my-connection-id",
+ Data: []byte{0x01, 0x02, 0x03},
+})
+
+// Close the connection when done
+_, err = websocket.Close(ctx, &websocket.CloseRequest{
+ ConnectionId: "my-connection-id",
+ Code: 1000, // Normal closure
+ Reason: "Done",
+})
+```
+
+#### SubsonicAPIService
+
+```protobuf
+service SubsonicAPIService {
+ rpc Call(CallRequest) returns (CallResponse);
+}
+```
+
+The SubsonicAPIService provides plugins with access to Navidrome's Subsonic API endpoints. This allows plugins to query and interact with Navidrome's music library data using the same API that external Subsonic clients use.
+
+Key features:
+
+- **Library Access**: Query artists, albums, tracks, playlists, and other music library data
+- **Search Functionality**: Search across the music library using various criteria
+- **Metadata Retrieval**: Get detailed information about music items including ratings, play counts, etc.
+- **Authentication Handled**: The service automatically handles authentication using internal auth context
+- **JSON Responses**: All responses are returned as JSON strings for easy parsing
+
+**Important Security Notes:**
+
+- Plugins must specify a username via the `u` parameter in the URL - this determines which user's library view and permissions apply
+- The service uses internal authentication, so plugins don't need to provide passwords or API keys
+- All Subsonic API security and access controls apply based on the specified user
+
+Example usage:
+
+```go
+// Get ping response to test connectivity
+resp, err := subsonicAPI.Call(ctx, &subsonicapi.CallRequest{
+ Url: "/rest/ping?u=admin",
+})
+if err != nil {
+ return err
+}
+// resp.Json contains the JSON response
+
+// Search for artists
+resp, err = subsonicAPI.Call(ctx, &subsonicapi.CallRequest{
+ Url: "/rest/search3?u=admin&query=Beatles&artistCount=10",
+})
+
+// Get album details
+resp, err = subsonicAPI.Call(ctx, &subsonicapi.CallRequest{
+ Url: "/rest/getAlbum?u=admin&id=123",
+})
+
+// Check for errors
+if resp.Error != "" {
+ // Handle error - could be missing parameters, invalid user, etc.
+ log.Printf("SubsonicAPI error: %s", resp.Error)
+}
+```
+
+**Common URL Patterns:**
+
+- `/rest/ping?u=USERNAME` - Test API connectivity
+- `/rest/search3?u=USERNAME&query=TERM` - Search library
+- `/rest/getArtists?u=USERNAME` - Get all artists
+- `/rest/getAlbum?u=USERNAME&id=ID` - Get album details
+- `/rest/getPlaylists?u=USERNAME` - Get user playlists
+
+**Required Parameters:**
+
+- `u` (username): Required for all requests - determines user context and permissions
+- `f=json`: Recommended to get JSON responses (easier to parse than XML)
+
+The service accepts standard Subsonic API endpoints and parameters. Refer to the [Subsonic API documentation](http://www.subsonic.org/pages/api.jsp) for complete endpoint details, but note that authentication parameters (`p`, `t`, `s`, `c`, `v`) are handled automatically.
+
+See the [subsonicapi.proto](host/subsonicapi/subsonicapi.proto) file for the full API definition.
+
+## Plugin Permission System
+
+Navidrome implements a permission-based security system that controls which host services plugins can access. This system enforces security at load-time by only making authorized services available to plugins in their WebAssembly runtime environment.
+
+### How Permissions Work
+
+The permission system follows a **secure-by-default** approach:
+
+1. **Default Behavior**: Plugins have access to **no host services** unless explicitly declared
+2. **Load-time Enforcement**: Only services listed in a plugin's permissions are loaded into its WASM runtime
+3. **Runtime Security**: Unauthorized services are completely unavailable - attempts to call them result in "function not exported" errors
+
+This design ensures that even if malicious code tries to access unauthorized services, the calls will fail because the functions simply don't exist in the plugin's runtime environment.
+
+### Permission Syntax
+
+Permissions are declared in the plugin's `manifest.json` file using the `permissions` field as an object:
+
+```json
+{
+ "name": "my-plugin",
+ "author": "Plugin Developer",
+ "version": "1.0.0",
+ "description": "A plugin that fetches data and caches results",
+ "website": "https://github.com/plugindeveloper/my-plugin",
+ "capabilities": ["MetadataAgent"],
+ "permissions": {
+ "http": {
+ "reason": "To fetch metadata from external APIs",
+ "allowedUrls": {
+ "https://api.musicbrainz.org": ["GET"],
+ "https://coverartarchive.org": ["GET"]
+ },
+ "allowLocalNetwork": false
+ },
+ "cache": {
+ "reason": "To cache API responses and reduce rate limiting"
+ },
+ "subsonicapi": {
+ "reason": "To query music library for artist and album information",
+ "allowedUsernames": ["metadata-user"],
+ "allowAdmins": false
+ }
+ }
+}
+```
+
+Each permission is represented as a key in the permissions object. The value must be an object containing a `reason` field that explains why the permission is needed.
+
+**Important**: Some permissions require additional configuration fields:
+
+- **`http`**: Requires `allowedUrls` object mapping URL patterns to allowed HTTP methods, and optional `allowLocalNetwork` boolean
+- **`websocket`**: Requires `allowedUrls` array of WebSocket URL patterns, and optional `allowLocalNetwork` boolean
+- **`subsonicapi`**: Requires `reason` field, with optional `allowedUsernames` array and `allowAdmins` boolean for fine-grained access control
+- **`config`**, **`cache`**, **`scheduler`**, **`artwork`**: Only require the `reason` field
+
+**Security Benefits of Required Reasons:**
+
+- **Transparency**: Users can see exactly what each plugin will do with its permissions
+- **Security Auditing**: Makes it easier to identify suspicious or overly broad permission requests
+- **Developer Accountability**: Forces plugin authors to justify each permission they request
+- **Trust Building**: Clear explanations help users make informed decisions about plugin installation
+
+If no permissions are needed, use an empty permissions object: `"permissions": {}`.
+
+### Available Permissions
+
+The following permission keys correspond to host services:
+
+| Permission | Host Service | Description | Required Fields |
+| ------------- | ------------------ | -------------------------------------------------- | ----------------------------------------------------- |
+| `http` | HttpService | Make HTTP requests (GET, POST, PUT, DELETE, etc..) | `reason`, `allowedUrls` |
+| `websocket` | WebSocketService | Connect to and communicate via WebSockets | `reason`, `allowedUrls` |
+| `cache` | CacheService | Store and retrieve cached data with TTL | `reason` |
+| `config` | ConfigService | Access Navidrome configuration values | `reason` |
+| `scheduler` | SchedulerService | Schedule one-time and recurring tasks | `reason` |
+| `artwork` | ArtworkService | Generate public URLs for artwork images | `reason` |
+| `subsonicapi` | SubsonicAPIService | Access Navidrome's Subsonic API endpoints | `reason`, optional: `allowedUsernames`, `allowAdmins` |
+
+#### HTTP Permission Structure
+
+HTTP permissions require explicit URL whitelisting for security:
+
+```json
+{
+ "http": {
+ "reason": "To fetch artist data from MusicBrainz and album covers from Cover Art Archive",
+ "allowedUrls": {
+ "https://musicbrainz.org/ws/2/*": ["GET"],
+ "https://coverartarchive.org/*": ["GET"],
+ "https://api.example.com/submit": ["POST"]
+ },
+ "allowLocalNetwork": false
+ }
+}
+```
+
+**Fields:**
+
+- `reason` (required): Explanation of why HTTP access is needed
+- `allowedUrls` (required): Object mapping URL patterns to allowed HTTP methods
+- `allowLocalNetwork` (optional, default false): Whether to allow requests to localhost/private IPs
+
+**URL Pattern Matching:**
+
+- Exact URLs: `"https://api.example.com/endpoint": ["GET"]`
+- Wildcard paths: `"https://api.example.com/*": ["GET", "POST"]`
+- Subdomain wildcards: `"https://*.example.com": ["GET"]`
+
+**Important**: Redirect destinations must also be included in `allowedUrls` if you want to follow redirects.
+
+#### WebSocket Permission Structure
+
+WebSocket permissions require explicit URL whitelisting:
+
+```json
+{
+ "websocket": {
+ "reason": "To connect to Discord gateway for real-time Rich Presence updates",
+ "allowedUrls": ["wss://gateway.discord.gg", "wss://*.discord.gg"],
+ "allowLocalNetwork": false
+ }
+}
+```
+
+**Fields:**
+
+- `reason` (required): Explanation of why WebSocket access is needed
+- `allowedUrls` (required): Array of WebSocket URL patterns (must start with `ws://` or `wss://`)
+- `allowLocalNetwork` (optional, default false): Whether to allow connections to localhost/private IPs
+
+#### SubsonicAPI Permission Structure
+
+SubsonicAPI permissions control which users plugins can access Navidrome's Subsonic API as, providing fine-grained security controls:
+
+```json
+{
+ "subsonicapi": {
+ "reason": "To query music library data for recommendation engine",
+ "allowedUsernames": ["plugin-user", "readonly-user"],
+ "allowAdmins": false
+ }
+}
+```
+
+**Fields:**
+
+- `reason` (required): Explanation of why SubsonicAPI access is needed
+- `allowedUsernames` (optional): Array of specific usernames the plugin is allowed to use. If empty or omitted, any username can be used
+- `allowAdmins` (optional, default false): Whether the plugin can make API calls using admin user accounts
+
+**Security Model:**
+
+The SubsonicAPI service enforces strict user-based access controls:
+
+- **Username Validation**: The plugin must provide a valid `u` (username) parameter in all API calls
+- **User Context**: All API responses are filtered based on the specified user's permissions and library access
+- **Admin Protection**: By default, plugins cannot use admin accounts for API calls to prevent privilege escalation
+- **Username Restrictions**: When `allowedUsernames` is specified, only those users can be used
+
+**Common Permission Patterns:**
+
+```jsonc
+// Allow any non-admin user (most permissive)
+{
+ "subsonicapi": {
+ "reason": "To search music library for metadata enhancement",
+ "allowAdmins": false
+ }
+}
+
+// Allow only specific users (most secure)
+{
+ "subsonicapi": {
+ "reason": "To access playlists for synchronization with external service",
+ "allowedUsernames": ["sync-user"],
+ "allowAdmins": false
+ }
+}
+
+// Allow admin users (use with caution)
+{
+ "subsonicapi": {
+ "reason": "To perform administrative tasks like library statistics",
+ "allowAdmins": true
+ }
+}
+
+// Restrict to specific users but allow admins
+{
+ "subsonicapi": {
+ "reason": "To backup playlists for authorized users only",
+ "allowedUsernames": ["backup-admin", "user1", "user2"],
+ "allowAdmins": true
+ }
+}
+```
+
+**Important Notes:**
+
+- Username matching is case-insensitive
+- If `allowedUsernames` is empty or omitted, any username can be used (subject to `allowAdmins` setting)
+- Admin restriction (`allowAdmins: false`) is checked after username validation
+- Invalid or non-existent usernames will result in API call errors
+
+### Permission Validation
+
+The plugin system validates permissions during loading:
+
+1. **Schema Validation**: The manifest is validated against the JSON schema
+2. **Permission Recognition**: Unknown permission keys are silently accepted for forward compatibility
+3. **Service Loading**: Only services with corresponding permissions are made available to the plugin
+
+### Security Model
+
+The permission system provides multiple layers of security:
+
+#### 1. Principle of Least Privilege
+
+- Plugins start with zero permissions
+- Only explicitly requested services are available
+- No way to escalate privileges at runtime
+
+#### 2. Load-time Enforcement
+
+- Unauthorized services are not loaded into the WASM runtime
+- No performance overhead for permission checks during execution
+- Impossible to bypass restrictions through code manipulation
+
+#### 3. Service Isolation
+
+- Each plugin gets its own isolated service instances
+- Plugins cannot interfere with each other's service usage
+- Host services are sandboxed within the WASM environment
+
+### Best Practices for Plugin Developers
+
+#### Request Minimal Permissions
+
+```jsonc
+// Good: No permissions if none needed
+{
+ "permissions": {}
+}
+
+// Good: Only request what you need with clear reasoning
+{
+ "permissions": {
+ "http": {
+ "reason": "To fetch artist biography from MusicBrainz database",
+ "allowedUrls": {
+ "https://musicbrainz.org/ws/2/artist/*": ["GET"]
+ },
+ "allowLocalNetwork": false
+ }
+ }
+}
+
+// Avoid: Requesting unnecessary permissions
+{
+ "permissions": {
+ "http": {
+ "reason": "To fetch data",
+ "allowedUrls": {
+ "https://*": ["*"]
+ },
+ "allowLocalNetwork": true
+ },
+ "cache": {
+ "reason": "For caching"
+ },
+ "scheduler": {
+ "reason": "For scheduling"
+ },
+ "websocket": {
+ "reason": "For real-time updates",
+ "allowedUrls": ["wss://*"],
+ "allowLocalNetwork": true
+ }
+ }
+}
+```
+
+#### Write Clear Permission Reasons
+
+Provide specific, descriptive reasons for each permission that explain exactly what the plugin does. Good reasons should:
+
+- Specify **what data** will be accessed/fetched
+- Mention **which external services** will be contacted (if applicable)
+- Explain **why** the permission is necessary for the plugin's functionality
+- Use clear, non-technical language that users can understand
+
+```jsonc
+// Good: Specific and informative
+{
+ "http": {
+ "reason": "To fetch album reviews from AllMusic API and artist biographies from MusicBrainz",
+ "allowedUrls": {
+ "https://www.allmusic.com/api/*": ["GET"],
+ "https://musicbrainz.org/ws/2/*": ["GET"]
+ },
+ "allowLocalNetwork": false
+ },
+ "cache": {
+ "reason": "To cache API responses for 24 hours to respect rate limits and improve performance"
+ }
+}
+
+// Bad: Vague and unhelpful
+{
+ "http": {
+ "reason": "To make requests",
+ "allowedUrls": {
+ "https://*": ["*"]
+ },
+ "allowLocalNetwork": true
+ },
+ "cache": {
+ "reason": "For caching"
+ }
+}
+```
+
+#### Handle Missing Permissions Gracefully
+
+Your plugin should provide clear error messages when permissions are missing:
+
+```go
+func (p *Plugin) GetArtistInfo(ctx context.Context, req *api.ArtistInfoRequest) (*api.ArtistInfoResponse, error) {
+ // This will fail with "function not exported" if http permission is missing
+ resp, err := p.httpClient.Get(ctx, &http.HttpRequest{Url: apiURL})
+ if err != nil {
+ // Check if it's a permission error
+ if strings.Contains(err.Error(), "not exported") {
+ return &api.ArtistInfoResponse{
+ Error: "Plugin requires 'http' permission (reason: 'To fetch artist metadata from external APIs') - please add to manifest.json",
+ }, nil
+ }
+ return &api.ArtistInfoResponse{Error: err.Error()}, nil
+ }
+ // ... process response
+}
+```
+
+### Troubleshooting Permissions
+
+#### Common Error Messages
+
+**"function not exported in module env"**
+
+- Cause: Plugin trying to call a service without proper permission
+- Solution: Add the required permission to your manifest.json
+
+**"manifest validation failed" or "missing required field"**
+
+- Cause: Plugin manifest is missing required fields (e.g., `allowedUrls` for HTTP/WebSocket permissions)
+- Solution: Ensure your manifest includes all required fields for each permission type
+
+**Permission silently ignored**
+
+- Cause: Using a permission key not recognized by current Navidrome version
+- Effect: The unknown permission is silently ignored (no error or warning)
+- Solution: This is actually normal behavior for forward compatibility
+
+#### Debugging Permission Issues
+
+1. **Check the manifest**: Ensure required permissions are spelled correctly and present
+2. **Verify required fields**: Check that HTTP and WebSocket permissions include `allowedUrls` and other required fields
+3. **Review logs**: Check for plugin loading errors, manifest validation errors, and WASM runtime errors
+4. **Test incrementally**: Add permissions one at a time to identify which services your plugin needs
+5. **Verify service names**: Ensure permission keys match exactly: `http`, `cache`, `config`, `scheduler`, `websocket`, `artwork`, `subsonicapi`
+6. **Validate manifest**: Use a JSON schema validator to check your manifest against the schema
+
+### Future Considerations
+
+The permission system is designed for extensibility:
+
+- **Unknown permissions** are allowed in manifests for forward compatibility
+- **New services** can be added with corresponding permission keys
+- **Permission scoping** could be added in the future (e.g., read-only vs. read-write access)
+
+This ensures that plugins developed today will continue to work as the system evolves, while maintaining strong security boundaries.
+
+## Plugin System Implementation
+
+Navidrome's plugin system is built using the following key libraries:
+
+### 1. WebAssembly Runtime (Wazero)
+
+The plugin system uses [Wazero](https://github.com/tetratelabs/wazero), a WebAssembly runtime written in pure Go. Wazero was chosen for several reasons:
+
+- **No CGO dependency**: Unlike other WebAssembly runtimes, Wazero is implemented in pure Go, which simplifies cross-compilation and deployment.
+- **Performance**: It provides efficient compilation and caching of WebAssembly modules.
+- **Security**: Wazero enforces strict sandboxing, which is important for running third-party plugin code safely.
+
+The plugin manager uses Wazero to:
+
+- Compile and cache WebAssembly modules
+- Create isolated runtime environments for each plugin
+- Instantiate plugin modules when they're called
+- Provide host functions that plugins can call
+
+### 2. Go-plugin Framework
+
+Navidrome builds on [go-plugin](https://github.com/knqyf263/go-plugin), a Go plugin system over WebAssembly that provides:
+
+- **Code generation**: Custom Protocol Buffer compiler plugin (`protoc-gen-go-plugin`) that generates Go code for both the host and WebAssembly plugins
+- **Host function system**: Framework for exposing host functionality to plugins safely
+- **Interface versioning**: Built-in mechanism for handling API compatibility between the host and plugins
+- **Type conversion**: Utilities for marshaling and unmarshaling data between Go and WebAssembly
+
+This framework significantly simplifies plugin development by handling the low-level details of WebAssembly communication, allowing plugin developers to focus on implementing capabilities interfaces.
+
+### 3. Protocol Buffers (Protobuf)
+
+[Protocol Buffers](https://developers.google.com/protocol-buffers) serve as the interface definition language for the plugin system. Navidrome uses:
+
+- **protoc-gen-go-plugin**: A custom protobuf compiler plugin that generates Go code for both the Navidrome host and WebAssembly plugins
+- Protobuf messages for structured data exchange between the host and plugins
+
+The protobuf definitions are located in:
+
+- `plugins/api/api.proto`: Core plugin capability interfaces
+- `plugins/host/http/http.proto`: HTTP service interface
+- `plugins/host/scheduler/scheduler.proto`: Scheduler service interface
+- `plugins/host/config/config.proto`: Config service interface
+- `plugins/host/websocket/websocket.proto`: WebSocket service interface
+- `plugins/host/cache/cache.proto`: Cache service interface
+- `plugins/host/artwork/artwork.proto`: Artwork service interface
+- `plugins/host/subsonicapi/subsonicapi.proto`: SubsonicAPI service interface
+
+### 4. Integration Architecture
+
+The plugin system integrates these libraries through several key components:
+
+- **Plugin Manager**: Manages the lifecycle of plugins, from discovery to loading
+- **Compilation Cache**: Improves performance by caching compiled WebAssembly modules
+- **Host Function Bridge**: Exposes Navidrome functionality to plugins through WebAssembly imports
+- **Capability Adapters**: Convert between the plugin API and Navidrome's internal interfaces
+
+Each plugin method call:
+
+1. Creates a new isolated plugin instance using Wazero
+2. Executes the method in the sandboxed environment
+3. Converts data between Go and WebAssembly formats using the protobuf-generated code
+4. Cleans up the instance after the call completes
+
+This stateless design ensures that plugins remain isolated and can't interfere with Navidrome's core functionality or each other.
+
+## Configuration
+
+Plugins are configured in Navidrome's main configuration via the `Plugins` section:
```toml
[Plugins]
+# Enable or disable plugin support
Enabled = true
+
+# Directory where plugins are stored (defaults to [DataFolder]/plugins)
Folder = "/path/to/plugins"
```
----
+By default, the plugins folder is created under `[DataFolder]/plugins` with restrictive permissions (`0700`) to limit access to the Navidrome user.
-## Plugin Basics
+### Plugin-specific Configuration
-### What is a Plugin?
-
-A Navidrome plugin is an `.ndp` package file (zip archive) containing:
-
-1. **`manifest.json`** – Plugin metadata (name, author, version, permissions)
-2. **`plugin.wasm`** – Compiled WebAssembly module with capability functions
-
-### Plugin Naming
-
-Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field:
-
-- `my-plugin.ndp` → plugin ID is `my-plugin`
-- The manifest `name` is the display name shown in the UI
-
-This allows users to have multiple instances of the same plugin with different configs by renaming the files.
-
-### The Manifest
-
-Every plugin must include a `manifest.json` file. Example:
-
-```json
-{
- "name": "My Plugin",
- "author": "Author Name",
- "version": "1.0.0",
- "description": "What this plugin does",
- "website": "https://example.com",
- "config": {
- "schema": { ... },
- "uiSchema": { ... }
- },
- "permissions": {
- "http": {
- "reason": "Fetch metadata from external API",
- "requiredHosts": ["api.example.com", "*.musicbrainz.org"]
- }
- }
-}
-```
-
-**Required fields:** `name`, `author`, `version`
-
-**Optional fields:** `description`, `website`, `config`, `permissions`
-
-#### Config Definition
-
-The `config` field defines the plugin's configuration schema using [JSON Schema (draft-07)](https://json-schema.org/) and an optional [JSONForms](https://jsonforms.io/) UI schema for rendering in the Navidrome web UI:
-
-```json
-{
- "config": {
- "schema": {
- "type": "object",
- "properties": {
- "api_key": { "type": "string", "title": "API Key" },
- "max_retries": { "type": "integer", "default": 3 }
- },
- "required": ["api_key"]
- },
- "uiSchema": {
- "api_key": { "ui:widget": "password" }
- }
- }
-}
-```
-
----
-
-## Capabilities
-
-Capabilities define what your plugin can do. They're automatically detected based on which functions you export. A plugin can implement multiple capabilities.
-
-### MetadataAgent
-
-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 |
-| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
-| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
-| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
-| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
-| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
-| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
-| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
-| `nd_get_similar_songs_by_track` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by track |
-| `nd_get_similar_songs_by_album` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by album |
-| `nd_get_similar_songs_by_artist` | `{id, name, mbid?, count}` | `{songs: [{name, artist}]}` | Similar songs by artist |
-
-To use the plugin as a metadata agent, add it to your config:
+You can also provide plugin-specific configuration using the `PluginConfig` section. Each plugin can have its own configuration map using the **folder name** as the key:
```toml
-Agents = "lastfm,spotify,my-plugin"
+[PluginConfig.my-plugin-folder]
+api_key = "your-api-key"
+user_id = "your-user-id"
+enable_feature = "true"
+
+[PluginConfig.another-plugin-folder]
+server_url = "https://example.com/api"
+timeout = "30"
```
-**Example (using Go PDK package):**
+These configuration values are passed to plugins during initialization through the `OnInit` method in the `LifecycleManagement` capability.
+Plugins that implement the `LifecycleManagement` capability will receive their configuration as a map of string keys and values.
-```go
-package main
+## Plugin Directory Structure
-import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
+Each plugin must be located in its own directory under the plugins folder:
-type myPlugin struct{}
-
-func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
- return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
-}
-
-func init() { metadata.Register(&myPlugin{}) }
-func main() {}
+```
+plugins/
+├── my-plugin/
+│ ├── plugin.wasm # Compiled WebAssembly module
+│ └── manifest.json # Plugin manifest defining metadata and capabilities
+├── another-plugin/
+│ ├── plugin.wasm
+│ └── manifest.json
```
-**Example (raw wasmexport):**
+**Note**: Plugin identification has changed! Navidrome now uses the **folder name** as the unique identifier for plugins, not the `name` field in `manifest.json`. This means:
-```go
-//go:wasmexport nd_get_artist_biography
-func ndGetArtistBiography() int32 {
- var input ArtistInput
- if err := pdk.InputJSON(&input); err != nil {
- pdk.SetError(err)
- return 1
- }
- pdk.OutputJSON(BiographyOutput{Biography: "Artist biography..."})
- return 0
-}
+- **Multiple plugins can have the same `name` in their manifest**, as long as they are in different folders
+- **Plugin loading and commands use the folder name**, not the manifest name
+- **Folder names must be unique** across all plugins in your plugins directory
+
+This change allows you to have multiple versions or variants of the same plugin (e.g., `lastfm-official`, `lastfm-custom`, `lastfm-dev`) that all have the same manifest name but coexist peacefully.
+
+### Example: Multiple Plugin Variants
+
+```
+plugins/
+├── lastfm-official/
+│ ├── plugin.wasm
+│ └── manifest.json # {"name": "LastFM Agent", ...}
+├── lastfm-custom/
+│ ├── plugin.wasm
+│ └── manifest.json # {"name": "LastFM Agent", ...}
+└── lastfm-dev/
+ ├── plugin.wasm
+ └── manifest.json # {"name": "LastFM Agent", ...}
```
-### Scrobbler
+All three plugins can have the same `"name": "LastFM Agent"` in their manifest, but they are identified and loaded by their folder names:
-Integrates with external scrobbling services. All four methods are **required**.
+```bash
+# Load specific variants
+navidrome plugin refresh lastfm-official
+navidrome plugin refresh lastfm-custom
+navidrome plugin refresh lastfm-dev
-| 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 |
+# Configure each variant separately
+[PluginConfig.lastfm-official]
+api_key = "production-key"
-> **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.
+[PluginConfig.lastfm-dev]
+api_key = "development-key"
+```
-**Manifest permission:**
+### Using Symlinks for Plugin Variants
+
+Symlinks provide a powerful way to create multiple configurations for the same plugin without duplicating files. When you create a symlink to a plugin directory, Navidrome treats the symlink as a separate plugin with its own configuration.
+
+**Example: Discord Rich Presence with Multiple Configurations**
+
+```bash
+# Create symlinks for different environments
+cd /path/to/navidrome/plugins
+ln -s /path/to/discord-rich-presence-plugin drp-prod
+ln -s /path/to/discord-rich-presence-plugin drp-dev
+ln -s /path/to/discord-rich-presence-plugin drp-test
+```
+
+Directory structure:
+
+```
+plugins/
+├── drp-prod -> /path/to/discord-rich-presence-plugin/
+├── drp-dev -> /path/to/discord-rich-presence-plugin/
+├── drp-test -> /path/to/discord-rich-presence-plugin/
+```
+
+Each symlink can have its own configuration:
+
+```toml
+[PluginConfig.drp-prod]
+clientid = "production-client-id"
+users = "admin:prod-token"
+
+[PluginConfig.drp-dev]
+clientid = "development-client-id"
+users = "admin:dev-token,testuser:test-token"
+
+[PluginConfig.drp-test]
+clientid = "test-client-id"
+users = "testuser:test-token"
+```
+
+**Key Benefits:**
+
+- **Single Source**: One plugin implementation serves multiple use cases
+- **Independent Configuration**: Each symlink has its own configuration namespace
+- **Development Workflow**: Easy to test different configurations without code changes
+- **Resource Sharing**: All symlinks share the same compiled WASM binary
+
+**Important Notes:**
+
+- The **symlink name** (not the target folder name) is used as the plugin ID
+- Configuration keys use the symlink name: `PluginConfig.`
+- Each symlink appears as a separate plugin in `navidrome plugin list`
+- CLI commands use the symlink name: `navidrome plugin refresh drp-dev`
+
+## Plugin Package Format (.ndp)
+
+Navidrome Plugin Packages (.ndp) are ZIP archives that bundle all files needed for a plugin. They can be installed using the `navidrome plugin install` command.
+
+### Package Structure
+
+A valid .ndp file must contain:
+
+```
+plugin-name.ndp (ZIP file)
+├── plugin.wasm # Required: The compiled WebAssembly module
+├── manifest.json # Required: Plugin manifest with metadata
+├── README.md # Optional: Documentation
+└── LICENSE # Optional: License information
+```
+
+### Creating a Plugin Package
+
+To create a plugin package:
+
+1. Compile your plugin to WebAssembly (plugin.wasm)
+2. Create a manifest.json file with required fields
+3. Include any documentation files you want to bundle
+4. Create a ZIP archive of all files
+5. Rename the ZIP file to have a .ndp extension
+
+### Installing a Plugin Package
+
+Use the Navidrome CLI to install plugins:
+
+```bash
+navidrome plugin install /path/to/plugin-name.ndp
+```
+
+This will extract the plugin to a directory in your configured plugins folder.
+
+## Plugin Management
+
+Navidrome provides a command-line interface for managing plugins. To use these commands, the plugin system must be enabled in your configuration.
+
+### Available Commands
+
+```bash
+# List all installed plugins
+navidrome plugin list
+
+# Show detailed information about a plugin package or installed plugin
+navidrome plugin info plugin-name-or-package.ndp
+
+# Install a plugin from a .ndp file
+navidrome plugin install /path/to/plugin.ndp
+
+# Remove an installed plugin (use folder name)
+navidrome plugin remove plugin-folder-name
+
+# Update an existing plugin
+navidrome plugin update /path/to/updated-plugin.ndp
+
+# Reload a plugin without restarting Navidrome (use folder name)
+navidrome plugin refresh plugin-folder-name
+
+# Create a symlink to a plugin development folder
+navidrome plugin dev /path/to/dev/folder
+```
+
+### Plugin Development
+
+The `dev` and `refresh` commands are particularly useful for plugin development:
+
+#### Development Workflow
+
+1. Create a plugin development folder with required files (`manifest.json` and `plugin.wasm`)
+2. Run `navidrome plugin dev /path/to/your/plugin` to create a symlink in the plugins directory
+3. Make changes to your plugin code
+4. Recompile the WebAssembly module
+5. Run `navidrome plugin refresh your-plugin-folder-name` to reload the plugin without restarting Navidrome
+
+The `dev` command creates a symlink from your development folder to the plugins directory, allowing you to edit the plugin files directly in your development environment without copying them to the plugins directory after each change.
+
+The refresh process:
+
+- Reloads the plugin manifest
+- Recompiles the WebAssembly module
+- Updates the plugin registration
+- Makes the updated plugin immediately available to Navidrome
+
+### Plugin Security
+
+Navidrome provides multiple layers of security for plugin execution:
+
+1. **WebAssembly Sandbox**: Plugins run in isolated WebAssembly environments with no direct system access
+2. **Permission System**: Plugins can only access host services they explicitly request in their manifest (see [Plugin Permission System](#plugin-permission-system))
+3. **File System Security**: The plugins folder is configured with restricted permissions (0700) accessible only by the user running Navidrome
+4. **Resource Isolation**: Each plugin instance is isolated and cannot interfere with other plugins or core Navidrome functionality
+
+The permission system ensures that plugins follow the principle of least privilege - they start with no access to host services and must explicitly declare what they need. This prevents malicious or poorly written plugins from accessing unauthorized functionality.
+
+Always ensure you trust the source of any plugins you install, and review their requested permissions before installation.
+
+## Plugin Manifest
+
+**Capability Names Are Case-Sensitive**: Entries in the `capabilities` array must exactly match one of the supported capabilities: `MetadataAgent`, `Scrobbler`, `SchedulerCallback`, `WebSocketCallback`, or `LifecycleManagement`.
+**Manifest Validation**: The `manifest.json` is validated against the embedded JSON schema (`plugins/schema/manifest.schema.json`). Invalid manifests will be rejected during plugin discovery.
+
+Every plugin must provide a `manifest.json` file that declares metadata, capabilities, and permissions:
```json
{
+ "name": "my-awesome-plugin",
+ "author": "Your Name",
+ "version": "1.0.0",
+ "description": "A plugin that does awesome things",
+ "website": "https://github.com/yourname/my-awesome-plugin",
+ "capabilities": [
+ "MetadataAgent",
+ "Scrobbler",
+ "SchedulerCallback",
+ "WebSocketCallback",
+ "LifecycleManagement"
+ ],
"permissions": {
- "users": {
- "reason": "Receive scrobble events for users assigned to this plugin"
+ "http": {
+ "reason": "To fetch metadata from external music APIs"
+ },
+ "cache": {
+ "reason": "To cache API responses and reduce rate limiting"
+ },
+ "config": {
+ "reason": "To read API keys and service configuration"
+ },
+ "scheduler": {
+ "reason": "To schedule periodic data refresh tasks"
}
}
}
```
-**NowPlaying/Scrobble Input:**
+Required fields:
-```json
-{
- "username": "john",
- "track": {
- "id": "track-id",
- "title": "Song Title",
- "album": "Album Name",
- "artist": "Artist Name",
- "albumArtist": "Album Artist",
- "duration": 180.5,
- "trackNumber": 1,
- "discNumber": 1,
- "mbzRecordingId": "...",
- "mbzAlbumId": "...",
- "mbzArtistId": "..."
- },
- "timestamp": 1703270400
-}
-```
+- `name`: Display name of the plugin (used for documentation/display purposes; folder name is used for identification)
+- `author`: The creator or organization behind the plugin
+- `version`: Version identifier (recommended to follow semantic versioning)
+- `description`: A brief description of what the plugin does
+- `website`: Website URL for the plugin documentation, source code, or homepage (must be a valid URI)
+- `capabilities`: Array of capability types the plugin implements
+- `permissions`: Object mapping host service names to their configurations (use empty object `{}` for no permissions)
-**PlaybackReport Input:**
+Currently supported capabilities:
-Same `username` and `track` fields, plus playback state details:
+- `MetadataAgent` - For implementing media metadata providers
+- `Scrobbler` - For implementing scrobbling plugins
+- `SchedulerCallback` - For implementing timed callbacks
+- `WebSocketCallback` - For interacting with WebSocket endpoints and handling WebSocket events
+- `LifecycleManagement` - For handling plugin initialization and configuration
-```json
-{
- "username": "john",
- "track": { ... },
- "state": "playing",
- "positionMs": 45000,
- "playbackRate": 1.0,
- "playerId": "player-id",
- "playerName": "My Client",
- "timestamp": 1703270400
-}
-```
+## Plugin Loading Process
-`state` is one of `starting`, `playing`, `paused`, `stopped`, or `expired`.
+1. The Plugin Manager scans the plugins directory and all subdirectories
+2. For each subdirectory containing a `plugin.wasm` file and valid `manifest.json`, the manager:
+ - Validates the manifest and checks for supported capabilities
+ - Pre-compiles the WASM module in the background
+ - Registers the plugin using the **folder name** as the unique identifier in the plugin registry
+3. Plugins can be loaded on-demand by folder name or all at once, depending on the manager's method calls
-**Error Handling:**
+## Writing a Plugin
-On success, return `0`. On failure, use `pdk.SetError()` with one of these error types:
+### Requirements
-- `scrobbler(not_authorized)` – User needs to re-authorize
-- `scrobbler(retry_later)` – Temporary failure, Navidrome will retry
-- `scrobbler(unrecoverable)` – Permanent failure, scrobble discarded
+1. Your plugin must be compiled to WebAssembly (WASM)
+2. Your plugin must implement at least one of the capability interfaces defined in `api.proto`
+3. Your plugin must be placed in its own directory with a proper `manifest.json`
+
+### Plugin Registration Functions
+
+The plugin API provides several registration functions that plugins can call during initialization to register capabilities and obtain host services. These functions should typically be called in your plugin's `init()` function.
+
+#### Standard Registration Functions
```go
-import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
-
-return scrobbler.ScrobblerErrorNotAuthorized
-return scrobbler.ScrobblerErrorRetryLater
-return scrobbler.ScrobblerErrorUnrecoverable
+func RegisterMetadataAgent(agent MetadataAgent)
+func RegisterScrobbler(scrobbler Scrobbler)
+func RegisterSchedulerCallback(callback SchedulerCallback)
+func RegisterLifecycleManagement(lifecycle LifecycleManagement)
+func RegisterWebSocketCallback(callback WebSocketCallback)
```
-### Lyrics
+These functions register plugins for the standard capability interfaces:
-Provides lyrics for tracks. The single method is **required**.
+- **RegisterMetadataAgent**: Register a plugin that provides artist/album metadata and images
+- **RegisterScrobbler**: Register a plugin that handles scrobbling to external services
+- **RegisterSchedulerCallback**: Register a plugin that handles scheduled callbacks (single callback per plugin)
+- **RegisterLifecycleManagement**: Register a plugin that handles initialization and configuration
+- **RegisterWebSocketCallback**: Register a plugin that handles WebSocket events
-| Function | Input | Output | Description |
-|-------------------------|-------------------------------|------------------------------------|-----------------|
-| `nd_lyrics_get_lyrics` | `{artistName, title, ...}` | `{lyrics: [{lang, text}]}` | Get lyrics |
+**Basic Usage Example:**
-Each returned lyric entry has a `lang` (language code) and `text` field. Multiple entries can be returned for different languages.
+```go
+type MyPlugin struct {
+ // plugin implementation
+}
-### SonicSimilarity
+func init() {
+ plugin := &MyPlugin{}
-Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are **required**.
+ // Register capabilities your plugin implements
+ api.RegisterScrobbler(plugin)
+ api.RegisterLifecycleManagement(plugin)
+}
+```
-| Function | Input | Output | Description |
-|---------------------------------|----------------------------------|--------------------------------------------|---------------------------------------|
-| `nd_get_sonic_similar_tracks` | `{song, count}` | `{matches: [{song, similarity}]}` | Find acoustically similar tracks |
-| `nd_find_sonic_path` | `{startSong, endSong, count}` | `{matches: [{song, similarity}]}` | Find a path between two songs |
+#### RegisterNamedSchedulerCallback
-Each match contains a `song` reference and a `similarity` score (float64, 0.0–1.0).
+```go
+func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService
+```
-### TaskWorker
+This function registers a named scheduler callback and returns a scheduler service instance. Named callbacks allow a single plugin to register multiple scheduler callbacks for different purposes, each with its own identifier.
-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.
+**Parameters:**
-| Function | Input | Output | Description |
-|---------------------|---------------------------------------------|---------|----------------------|
-| `nd_task_execute` | `{queueName, taskID, payload, attempt}` | `string`| Execute a queued task|
+- `name` (string): A unique identifier for this scheduler callback within the plugin. This name is used to route scheduled events to the correct callback handler.
+- `cb` (SchedulerCallback): An object that implements the `SchedulerCallback` interface
-The `payload` is raw bytes (the same bytes passed to `TaskEnqueue`). The `attempt` counter starts at 1 and increments on retries. Return a string result on success.
+**Returns:**
-### Lifecycle
+- `scheduler.SchedulerService`: A scheduler service instance that can be used to schedule one-time or recurring tasks for this specific callback
-Optional initialization callback. Called once after the plugin fully loads.
+**Usage Example** (from Discord Rich Presence plugin):
-| Function | Input | Output | Description |
-|--------------|-------|------------|--------------------------------|
-| `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads |
+```go
+func init() {
+ // Register multiple named scheduler callbacks for different purposes
+ plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
+ plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
+}
-Useful for initializing connections, scheduling recurring tasks, etc. Errors are logged but don't prevent the plugin from loading.
+// The plugin implements SchedulerCallback to handle "close-activity" events
+func (d *DiscordRPPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
+ log.Printf("Removing presence for user %s", req.ScheduleId)
+ // Handle close-activity scheduling events
+ return nil, d.rpc.clearActivity(ctx, req.ScheduleId)
+}
-### SchedulerCallback
+// The rpc component implements SchedulerCallback to handle "heartbeat" events
+func (r *discordRPC) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
+ // Handle heartbeat scheduling events
+ return nil, r.sendHeartbeat(ctx, req.ScheduleId)
+}
-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.
+// Use the returned scheduler service to schedule tasks
+func (d *DiscordRPPlugin) NowPlaying(ctx context.Context, request *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) {
+ // Schedule a one-time callback to clear activity when track ends
+ _, err = d.sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{
+ ScheduleId: request.Username,
+ DelaySeconds: request.Track.Length - request.Track.Position + 5,
+ })
+ return nil, err
+}
-| Function | Input | Output | Description |
-|---------------------------|----------------------------------------------|--------|-----------------------------|
-| `nd_scheduler_callback` | `{scheduleId, payload, isRecurring}` | (none) | Handle scheduled task event |
+func (r *discordRPC) connect(ctx context.Context, username string, token string) error {
+ // Schedule recurring heartbeats for Discord connection
+ _, err := r.sched.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{
+ CronExpression: "@every 41s",
+ ScheduleId: username,
+ })
+ return err
+}
+```
-### WebSocketCallback
+**Key Benefits:**
-Receives WebSocket events. Export any subset of these to handle events from the [WebSocket](#websocket) host service.
+- **Multiple Schedulers**: A single plugin can have multiple named scheduler callbacks for different purposes (e.g., "heartbeat", "cleanup", "refresh")
+- **Isolated Scheduling**: Each named callback gets its own scheduler service, allowing independent scheduling management
+- **Clear Separation**: Different callback handlers can be implemented on different objects within your plugin
+- **Flexible Routing**: The scheduler automatically routes callbacks to the correct handler based on the registration name
-| Function | Input | Description |
-|----------------------------------|---------------------------------|----------------------------------|
-| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received |
-| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) |
-| `nd_websocket_on_error` | `{connectionId, error}` | Connection error |
-| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed |
+**Important Notes:**
-Each callback invocation is subject to a 30-second timeout.
+- The `name` parameter must be unique within your plugin, but can be the same across different plugins
+- The returned scheduler service is specifically tied to the named callback you registered
+- Scheduled events will call the `OnSchedulerCallback` method on the object you provided during registration
+- You must implement the `SchedulerCallback` interface on the object you register
----
+#### RegisterSchedulerCallback vs RegisterNamedSchedulerCallback
+
+- **Use `RegisterSchedulerCallback`** when your plugin only needs a single scheduler callback
+- **Use `RegisterNamedSchedulerCallback`** when your plugin needs multiple scheduler callbacks for different purposes (like the Discord plugin's "heartbeat" and "close-activity" callbacks)
+
+The named version allows better organization and separation of concerns when you have complex scheduling requirements.
+
+### Capability Interfaces
+
+#### Metadata Agent
+
+A capability fetches metadata about artists and albums. Implement this interface to add support for fetching data from external sources.
+
+```protobuf
+service MetadataAgent {
+ // Artist metadata methods
+ rpc GetArtistMBID(ArtistMBIDRequest) returns (ArtistMBIDResponse);
+ rpc GetArtistURL(ArtistURLRequest) returns (ArtistURLResponse);
+ rpc GetArtistBiography(ArtistBiographyRequest) returns (ArtistBiographyResponse);
+ rpc GetSimilarArtists(ArtistSimilarRequest) returns (ArtistSimilarResponse);
+ rpc GetArtistImages(ArtistImageRequest) returns (ArtistImageResponse);
+ rpc GetArtistTopSongs(ArtistTopSongsRequest) returns (ArtistTopSongsResponse);
+
+ // Album metadata methods
+ rpc GetAlbumInfo(AlbumInfoRequest) returns (AlbumInfoResponse);
+ rpc GetAlbumImages(AlbumImagesRequest) returns (AlbumImagesResponse);
+}
+```
+
+#### Scrobbler
+
+This capability enables scrobbling to external services. Implement this interface to add support for custom scrobblers.
+
+```protobuf
+service Scrobbler {
+ rpc IsAuthorized(ScrobblerIsAuthorizedRequest) returns (ScrobblerIsAuthorizedResponse);
+ rpc NowPlaying(ScrobblerNowPlayingRequest) returns (ScrobblerNowPlayingResponse);
+ rpc Scrobble(ScrobblerScrobbleRequest) returns (ScrobblerScrobbleResponse);
+}
+```
+
+#### Scheduler Callback
+
+This capability allows plugins to receive one-time or recurring scheduled callbacks. Implement this interface to add
+support for scheduled tasks. See the [SchedulerService](#scheduler-service) for more information.
+
+```protobuf
+service SchedulerCallback {
+ rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse);
+}
+```
+
+#### WebSocket Callback
+
+This capability allows plugins to interact with WebSocket endpoints and handle WebSocket events. Implement this interface to add support for WebSocket-based communication.
+
+```protobuf
+service WebSocketCallback {
+ // Called when a text message is received
+ rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse);
+
+ // Called when a binary message is received
+ rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse);
+
+ // Called when an error occurs
+ rpc OnError(OnErrorRequest) returns (OnErrorResponse);
+
+ // Called when the connection is closed
+ rpc OnClose(OnCloseRequest) returns (OnCloseResponse);
+}
+```
+
+Plugins can use the WebSocket host service to connect to WebSocket endpoints, send messages, and handle responses:
+
+```go
+// Define a connection ID first
+connectionID := "my-connection-id"
+
+// Connect to a WebSocket server
+connectResp, err := websocket.Connect(ctx, &websocket.ConnectRequest{
+ Url: "wss://example.com/ws",
+ Headers: map[string]string{"Authorization": "Bearer token"},
+ ConnectionId: connectionID,
+})
+if err != nil {
+ return err
+}
+
+// Send a text message
+_, err = websocket.SendText(ctx, &websocket.SendTextRequest{
+ ConnectionId: connectionID,
+ Message: "Hello WebSocket",
+})
+
+// Close the connection when done
+_, err = websocket.Close(ctx, &websocket.CloseRequest{
+ ConnectionId: connectionID,
+ Code: 1000, // Normal closure
+ Reason: "Done",
+})
+```
## Host Services
-Host services let your plugin call back into Navidrome for advanced functionality. Each service (except [Config](#config)) requires declaring the corresponding permission in your manifest.
+Navidrome provides several host services that plugins can use to interact with external systems and access functionality. Plugins must declare permissions for each service they want to use in their `manifest.json`.
-### Go PDK Setup
+### HTTP Service
-All host service examples below use the generated Go SDK. Add this to your `go.mod`:
+The HTTP service allows plugins to make HTTP requests to external APIs and services. To use this service, declare the `http` permission in your manifest.
-```
-require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
-replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
-```
-
-Then import:
-
-```go
-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).
-
-**Manifest permission:**
+#### Basic Usage
```json
{
"permissions": {
"http": {
- "reason": "Fetch metadata from external API",
- "requiredHosts": ["api.example.com", "*.musicbrainz.org"]
+ "reason": "To fetch artist metadata from external music APIs"
}
}
}
```
-**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.
+#### Granular Permissions
-**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
-resp, err := host.HTTPSend(host.HTTPRequest{
- Method: "GET",
- URL: "https://api.example.com/data",
- Headers: map[string]string{"Authorization": "Bearer " + apiKey},
-})
-if resp.StatusCode == 200 {
- // Process resp.Body
-}
-```
-
-### Scheduler
-
-Schedule one-time or recurring tasks. Your plugin must export the [`nd_scheduler_callback`](#schedulercallback) function to receive events.
-
-**Manifest permission:**
+For enhanced security, you can specify granular HTTP permissions that restrict which URLs and HTTP methods your plugin can access:
```json
{
"permissions": {
- "scheduler": {
- "reason": "Schedule periodic metadata refresh"
+ "http": {
+ "reason": "To fetch album reviews from AllMusic and artist data from MusicBrainz",
+ "allowedUrls": {
+ "https://api.allmusic.com": ["GET", "POST"],
+ "https://*.musicbrainz.org": ["GET"],
+ "https://coverartarchive.org": ["GET"],
+ "*": ["GET"]
+ },
+ "allowLocalNetwork": false
}
}
}
```
-**Host functions:**
+**Permission Fields:**
-| Function | Parameters | Description |
-|-------------------------------|------------------------------------------|-----------------------------|
-| `scheduler_scheduleonetime` | `delaySeconds, payload, scheduleId?` | Schedule one-time callback |
-| `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback |
-| `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task |
+- `reason` (required): Clear explanation of why HTTP access is needed
+- `allowedUrls` (required): Map of URL patterns to allowed HTTP methods
-**Usage:**
+ - Must contain at least one URL pattern
+ - For unrestricted access, use: `{"*": ["*"]}`
+ - Keys can be exact URLs, wildcard patterns, or `*` for any URL
+ - Values are arrays of HTTP methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, or `*` for any method
+ - **Important**: Redirect destinations must also be included in this list. If a URL redirects to another URL not in `allowedUrls`, the redirect will be blocked.
-```go
-// Schedule one-time task in 60 seconds
-scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "")
+- `allowLocalNetwork` (optional, default: `false`): Whether to allow requests to localhost/private IPs
-// Schedule recurring task with cron expression (every hour)
-scheduleID, err := host.SchedulerScheduleRecurring("0 * * * *", "hourly-task", "")
+**URL Pattern Matching:**
-// Cancel a task
-err := host.SchedulerCancelSchedule(scheduleID)
+- Exact URLs: `https://api.example.com`
+- Wildcard subdomains: `https://*.example.com` (matches any subdomain)
+- Wildcard paths: `https://example.com/api/*` (matches any path under /api/)
+- Global wildcard: `*` (matches any URL - use with caution)
+
+**Examples:**
+
+```json
+// Allow only GET requests to specific APIs
+{
+ "allowedUrls": {
+ "https://api.last.fm": ["GET"],
+ "https://ws.audioscrobbler.com": ["GET"]
+ }
+}
+
+// Allow any method to a trusted domain, GET everywhere else
+{
+ "allowedUrls": {
+ "https://my-trusted-api.com": ["*"],
+ "*": ["GET"]
+ }
+}
+
+// Handle redirects by including redirect destinations
+{
+ "allowedUrls": {
+ "https://short.ly/api123": ["GET"], // Original URL
+ "https://api.actual-service.com": ["GET"] // Redirect destination
+ }
+}
+
+// Strict permissions for a secure plugin (blocks redirects by not including redirect destinations)
+{
+ "allowedUrls": {
+ "https://api.musicbrainz.org/ws/2": ["GET"]
+ },
+ "allowLocalNetwork": false
+}
```
-### Cache
+#### Security Considerations
-In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart.
+The HTTP service implements several security features:
-**Manifest permission:**
+1. **Local Network Protection**: By default, requests to localhost and private IP ranges are blocked
+2. **URL Filtering**: Only URLs matching `allowedUrls` patterns are allowed
+3. **Method Restrictions**: HTTP methods are validated against the allowed list for each URL pattern
+4. **Redirect Security**:
+ - Redirect destinations must also match `allowedUrls` patterns and methods
+ - Maximum of 5 redirects per request to prevent redirect loops
+ - To block all redirects, simply don't include any redirect destinations in `allowedUrls`
+
+**Private IP Ranges Blocked (when `allowLocalNetwork: false`):**
+
+- IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `169.254.0.0/16`
+- IPv6: `::1`, `fe80::/10`, `fc00::/7`
+- Hostnames: `localhost`
+
+#### Making HTTP Requests
+
+```go
+import "github.com/navidrome/navidrome/plugins/host/http"
+
+// GET request
+resp, err := httpClient.Get(ctx, &http.HttpRequest{
+ Url: "https://api.example.com/data",
+ Headers: map[string]string{
+ "Authorization": "Bearer " + token,
+ "User-Agent": "MyPlugin/1.0",
+ },
+ TimeoutMs: 5000,
+})
+
+// POST request with body
+resp, err := httpClient.Post(ctx, &http.HttpRequest{
+ Url: "https://api.example.com/submit",
+ Headers: map[string]string{
+ "Content-Type": "application/json",
+ },
+ Body: []byte(`{"key": "value"}`),
+ TimeoutMs: 10000,
+})
+
+// Handle response
+if err != nil {
+ return &api.Response{Error: "HTTP request failed: " + err.Error()}, nil
+}
+
+if resp.Error != "" {
+ return &api.Response{Error: "HTTP error: " + resp.Error}, nil
+}
+
+if resp.Status != 200 {
+ return &api.Response{Error: fmt.Sprintf("HTTP %d: %s", resp.Status, string(resp.Body))}, nil
+}
+
+// Use response data
+data := resp.Body
+headers := resp.Headers
+```
+
+### Other Host Services
+
+#### Config Service
+
+Access plugin-specific configuration:
+
+```json
+{
+ "permissions": {
+ "config": {
+ "reason": "To read API keys and service endpoints from plugin configuration"
+ }
+ }
+}
+```
+
+#### Cache Service
+
+Store and retrieve data to improve performance:
```json
{
"permissions": {
"cache": {
- "reason": "Cache API responses to reduce external requests"
+ "reason": "To cache API responses and reduce external service calls"
}
}
}
```
-**Host functions:**
+#### Scheduler Service
-| Function | Parameters | Description |
-|-------------------|---------------------------|-----------------------|
-| `cache_setstring` | `key, value, ttl_seconds` | Store a string |
-| `cache_getstring` | `key` | Get a string |
-| `cache_setint` | `key, value, ttl_seconds` | Store an integer |
-| `cache_getint` | `key` | Get an integer |
-| `cache_setfloat` | `key, value, ttl_seconds` | Store a float |
-| `cache_getfloat` | `key` | Get a float |
-| `cache_setbytes` | `key, value, ttl_seconds` | Store bytes |
-| `cache_getbytes` | `key` | Get bytes |
-| `cache_has` | `key` | Check if key exists |
-| `cache_remove` | `key` | Delete a cached value |
-
-**TTL:** Pass `0` for the default (24 hours), or specify seconds.
-
-**Usage:**
-
-```go
-// Cache a value for 1 hour
-host.CacheSetString("api-response", responseData, 3600)
-
-// Retrieve (returns value, exists, error)
-value, exists, err := host.CacheGetString("api-response")
-if exists {
- // Use value
-}
-```
-
-### KVStore
-
-Persistent key-value storage backed by SQLite. Survives server restarts. Each plugin has its own isolated database at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
-
-**Manifest permission:**
+Schedule recurring or one-time tasks:
```json
{
"permissions": {
- "kvstore": {
- "reason": "Store OAuth tokens and plugin state",
- "maxSize": "1MB"
+ "scheduler": {
+ "reason": "To schedule periodic metadata refresh and cleanup tasks"
}
}
}
```
-- `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB
+#### WebSocket Service
-**Key constraints:** Maximum 256 bytes, must be valid UTF-8.
-
-**Host functions:**
-
-| Function | Parameters | Description |
-|-----------------------------|--------------------------|-----------------------------------|
-| `kvstore_set` | `key, value` | Store a byte value |
-| `kvstore_setwithttl` | `key, value, ttlSeconds` | Store with auto-expiration |
-| `kvstore_get` | `key` | Retrieve a byte value |
-| `kvstore_getmany` | `keys` | Retrieve multiple values at once |
-| `kvstore_has` | `key` | Check if key exists |
-| `kvstore_list` | `prefix` | List keys matching prefix |
-| `kvstore_delete` | `key` | Delete a value |
-| `kvstore_deletebyprefix` | `prefix` | Delete all keys matching prefix |
-| `kvstore_getstorageused` | – | Get current storage usage (bytes) |
-
-**Usage:**
-
-```go
-// Store a value (as raw bytes)
-token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`)
-host.KVStoreSet("oauth:spotify", token)
-
-// Store with TTL (auto-expires after 1 hour)
-host.KVStoreSetWithTTL("session:abc", sessionData, 3600)
-
-// Retrieve a value
-value, exists, err := host.KVStoreGet("oauth:spotify")
-if exists {
- var tokenData map[string]string
- json.Unmarshal(value, &tokenData)
-}
-
-// Batch retrieve
-results, err := host.KVStoreGetMany([]string{"key1", "key2", "key3"})
-
-// List and delete by prefix
-keys, err := host.KVStoreList("user:")
-host.KVStoreDeleteByPrefix("user:")
-
-// Check storage usage
-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.
-
-**Manifest permission:**
-
-```json
-{
- "permissions": {
- "taskqueue": {
- "reason": "Process audio analysis in the background",
- "maxConcurrency": 2
- }
- }
-}
-```
-
-**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
-
-**Usage:**
-
-```go
-// Create a queue with retry configuration
-host.TaskCreateQueue("analysis", host.QueueConfig{
- Concurrency: 2,
- MaxRetries: 3,
- BackoffMs: 1000,
-})
-
-// Enqueue a task
-taskID, err := host.TaskEnqueue("analysis", []byte(`{"trackId": "abc"}`))
-
-// Check task status
-info, err := host.TaskGet(taskID)
-fmt.Printf("Status: %s, Attempt: %d\n", info.Status, info.Attempt)
-```
-
-### WebSocket
-
-Establish persistent WebSocket connections to external services. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events.
-
-**Manifest permission:**
+Connect to WebSocket endpoints:
```json
{
"permissions": {
"websocket": {
- "reason": "Real-time connection to service",
- "requiredHosts": ["gateway.example.com", "*.discord.gg"]
+ "reason": "To connect to real-time music service APIs for live data",
+ "allowedUrls": [
+ "wss://api.musicservice.com/ws",
+ "wss://realtime.example.com"
+ ],
+ "allowLocalNetwork": false
}
}
}
```
-`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).
+#### Artwork Service
-**Host functions:**
-
-| Function | Parameters | Description |
-|----------------------------|---------------------------------|-------------------|
-| `websocket_connect` | `url, headers?, connectionId?` | Open a connection |
-| `websocket_sendtext` | `connectionId, message` | Send text message |
-| `websocket_sendbinary` | `connectionId, data` | Send binary data |
-| `websocket_closeconnection`| `connectionId, code?, reason?` | Close connection |
-
-**Usage:**
-
-```go
-connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "")
-host.WebSocketSendText(connID, `{"op": 1, "d": null}`)
-host.WebSocketCloseConnection(connID, 1000, "done")
-```
-
-### Library
-
-Access music library metadata and optionally read files from library directories.
-
-**Manifest permission:**
-
-```json
-{
- "permissions": {
- "library": {
- "reason": "Access library metadata for analysis",
- "filesystem": false
- }
- }
-}
-```
-
-- `filesystem` – Set to `true` to enable access to library directories, read-only unless an administrator grants write access (default: `false`)
-
-**Host functions:**
-
-| Function | Parameters | Returns |
-|----------------------------|------------|---------------------------|
-| `library_getlibrary` | `id` | Library metadata |
-| `library_getalllibraries` | (none) | Array of library metadata |
-
-**Library metadata:**
-
-```json
-{
- "id": 1,
- "name": "My Music",
- "path": "/music/collection",
- "mountPoint": "/libraries/1",
- "lastScanAt": 1703270400,
- "totalSongs": 5000,
- "totalAlbums": 500,
- "totalArtists": 200,
- "totalSize": 50000000000,
- "totalDuration": 1500000.5
-}
-```
-
-> **Note:** The `path` and `mountPoint` fields are only included when `filesystem: true` is set in the permission.
-
-**Filesystem access:**
-
-When `filesystem: true`, your plugin can read files from library directories via WASI filesystem APIs. Each library is mounted at `/libraries/`:
-
-```go
-import "os"
-
-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`).
-
-**Usage:**
-
-```go
-// Get a specific library
-library, err := host.LibraryGetLibrary(1)
-fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs)
-
-// Get all libraries
-libraries, err := host.LibraryGetAllLibraries()
-for _, lib := range libraries {
- fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs)
-}
-```
-
-### 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).
-
-**Manifest permission:**
+Generate public URLs for artwork:
```json
{
"permissions": {
"artwork": {
- "reason": "Get artwork URLs for display"
+ "reason": "To generate public URLs for album and artist images"
}
}
}
```
-**Host functions:**
+### Error Handling
-| Function | Parameters | Returns |
-|--------------------------|------------|-------------|
-| `artwork_getartisturl` | `id, size` | Artwork URL |
-| `artwork_getalbumurl` | `id, size` | Artwork URL |
-| `artwork_gettrackurl` | `id, size` | Artwork URL |
-| `artwork_getplaylisturl` | `id, size` | Artwork URL |
+Plugins should use the standard error values (`plugin:not_found`, `plugin:not_implemented`) to indicate resource-not-found and unimplemented-method scenarios. All other errors will be propagated directly to the caller. Ensure your capability methods return errors via the response message `error` fields rather than panicking or relying on transport errors.
-**Usage:**
+## Plugin Lifecycle and Statelessness
+
+**Important**: Navidrome plugins are stateless. Each method call creates a new plugin instance which is destroyed afterward. This has several important implications:
+
+1. **No in-memory persistence**: Plugins cannot store state between method calls in memory
+2. **Each call is isolated**: Variables, configurations, and runtime state don't persist between calls
+3. **No shared resources**: Each plugin instance has its own memory space
+
+This stateless design is crucial for security and stability:
+
+- Memory leaks in one call won't affect subsequent operations
+- A crashed plugin instance won't bring down the entire system
+- Resource usage is more predictable and contained
+
+When developing plugins, keep these guidelines in mind:
+
+- Don't try to cache data in memory between calls
+- Don't store authentication tokens or session data in variables
+- If persistence is needed, use external storage or the host's HTTP interface
+- Performance optimizations should focus on efficient per-call execution
+
+### Using Plugin Configuration
+
+Since plugins are stateless, you can use the `LifecycleManagement` interface to read configuration when your plugin is loaded and perform any necessary setup:
```go
-url, err := host.ArtworkGetAlbumUrl("album-id", 300)
-```
-
-### SubsonicAPI
-
-Call Navidrome's Subsonic API internally (no network round-trip).
-
-**Manifest permission:**
-
-```json
-{
- "permissions": {
- "subsonicapi": {
- "reason": "Access library data"
- },
- "users": {
- "reason": "Access user information for SubsonicAPI authorization"
+func (p *myPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
+ // Access plugin configuration
+ apiKey := req.Config["api_key"]
+ if apiKey == "" {
+ return &api.InitResponse{Error: "Missing API key in configuration"}, nil
}
- }
-}
-```
-> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI.
-
-**Host functions:**
-
-| Function | Parameters | Returns |
-|-----------------------|------------|--------------------------------|
-| `subsonicapi_call` | `uri` | JSON response string |
-| `subsonicapi_callraw` | `uri` | Content type + binary response |
-
-**Usage:**
-
-```go
-// JSON response
-response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
-
-// Binary response (e.g., cover art, streams)
-contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username")
-```
-
-### Config
-
-Access plugin configuration values. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys — useful for discovering dynamic configuration.
-
-> **Note:** This service is always available and does not require a manifest permission.
-
-**Host functions:**
-
-| Function | Parameters | Returns |
-|-----------------|------------|-----------------------------|
-| `config_get` | `key` | `value, exists` |
-| `config_getint` | `key` | `value, exists` |
-| `config_keys` | `prefix` | Array of matching key names |
-
-**Usage:**
-
-```go
-// Get a configuration value
-value, exists := host.ConfigGet("api_key")
-
-// Get an integer configuration value
-count, exists := host.ConfigGetInt("max_retries")
-
-// List all keys with a prefix (useful for user-specific config)
-keys := host.ConfigKeys("user:")
-
-// List all configuration keys
-allKeys := host.ConfigKeys("")
-```
-
-### Users
-
-Access user information for the users that the plugin has been granted access to.
-
-**Manifest permission:**
-
-```json
-{
- "permissions": {
- "users": {
- "reason": "Display user information in status updates"
+ // Validate configuration
+ serverURL := req.Config["server_url"]
+ if serverURL == "" {
+ serverURL = "https://default-api.example.com" // Use default if not specified
}
- }
-}
-```
-**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access:
-
-1. **Allow all users** – Enable the "Allow all users" toggle in the plugin settings
-2. **Select specific users** – Choose individual users from the user list
-
-If neither option is configured, the plugin cannot be enabled.
-
-**Host functions:**
-
-| Function | Parameters | Returns |
-|------------------|------------|-----------------------|
-| `users_getusers` | – | Array of User objects |
-| `users_getadmins`| – | Array of admin Users |
-
-**User object fields:**
-
-| Field | Type | Description |
-|------------|---------|--------------------------------|
-| `userName` | string | The user's unique username |
-| `name` | string | The user's display name |
-| `isAdmin` | boolean | Whether the user is an admin |
-
-> **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins.
-
-**Usage:**
-
-```go
-users, err := host.UsersGetUsers()
-for _, user := range users {
- pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")")
-}
-
-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)
+ // Perform initialization tasks (e.g., validate API key)
+ httpClient := &http.HttpServiceClient{}
+ resp, err := httpClient.Get(ctx, &http.HttpRequest{
+ Url: serverURL + "/validate?key=" + apiKey,
+ })
if err != nil {
- return err
+ return &api.InitResponse{Error: "Failed to validate API key: " + err.Error()}, nil
}
- all = append(all, page...)
- if next == nil {
- break // no more scrobbles
+
+ if resp.StatusCode != 200 {
+ return &api.InitResponse{Error: "Invalid API key"}, nil
}
- 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
-
-### Server Configuration
-
-Enable plugins in `navidrome.toml`:
-
-```toml
-[Plugins]
-Enabled = true
-Folder = "/path/to/plugins" # Default: DataFolder/plugins
-AutoReload = true # Auto-reload on file changes (dev mode)
-LogLevel = "debug" # Plugin-specific log level
-CacheSize = "200MB" # Compilation cache size limit
-```
-
-### Plugin Configuration
-
-Plugin configuration is managed through the Navidrome web UI. Navigate to the Plugins page, select a plugin, and edit its configuration as key-value pairs.
-
-Access configuration values in your plugin:
-
-```go
-apiKey, ok := pdk.GetConfig("api_key")
-if !ok {
- pdk.SetErrorString("api_key configuration is required")
- return 1
+ return &api.InitResponse{}, nil
}
```
-For more advanced access (listing keys, integer values), use the [Config](#config) host service.
+Remember, the `OnInit` method is called only once when the plugin is loaded. It cannot store any state that needs to persist between method calls. It's primarily useful for:
----
+1. Validating required configuration
+2. Checking API credentials
+3. Verifying connectivity to external services
+4. Initializing any external resources
-## Command Line Interface
+## Caching
-Manage plugins from the command line with `navidrome plugin`:
+The plugin system implements a compilation cache to improve performance:
-| 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 |
+1. Compiled WASM modules are cached in `[CacheFolder]/plugins`
+2. This reduces startup time for plugins that have already been compiled
+3. The cache has a automatic cleanup mechanism to remove old modules.
+ - when the cache folder exceeds `Plugins.CacheSize` (default 100MB),
+ the oldest modules are removed
-**`plugin edit` flags:**
+### WASM Loading Optimization
-- `--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
+To improve performance during plugin instance creation, the system implements an optimization that avoids repeated file reads and compilation:
----
+1. **Precompilation**: During plugin discovery, WASM files are read and compiled in the background, with both the MD5 hash of the file bytes and compiled modules cached in memory.
-## Building Plugins
+2. **Optimized Runtime**: After precompilation completes, plugins use an `optimizedRuntime` wrapper that overrides `CompileModule` to detect when the same WASM bytes are being compiled by comparing MD5 hashes.
-### Supported Languages
+3. **Cache Hit**: When the generated plugin code calls `os.ReadFile()` and `CompileModule()`, the optimization calculates the MD5 hash of the incoming bytes and compares it with the cached hash. If they match, it returns the pre-compiled module directly.
-Plugins can be written in any language that Extism supports. We recommend:
+4. **Performance Benefit**: This eliminates repeated compilation while using minimal memory (16 bytes per plugin for the MD5 hash vs potentially MB of WASM bytes), significantly improving plugin instance creation speed while maintaining full compatibility with the generated API code.
-- **Go** – Best overall experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk). Familiar syntax, excellent stdlib support.
-- **Rust** – Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the [Rust PDK](https://github.com/extism/rust-pdk).
-- **Python** – Best for rapid prototyping. Experimental support via [extism-py](https://github.com/extism/python-pdk). Note some limitations compared to compiled languages.
-- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk).
+5. **Memory Efficiency**: By storing only MD5 hashes instead of full WASM bytes, the optimization scales efficiently regardless of plugin size or count.
-### Go with TinyGo (Recommended)
+The optimization is transparent to plugin developers and automatically activates when plugins are successfully precompiled.
-```bash
-# Install TinyGo: https://tinygo.org/getting-started/install/
+## Best Practices
-# Build WebAssembly module
-tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared .
+1. **Resource Management**:
-# Package as .ndp
-zip -j my-plugin.ndp manifest.json plugin.wasm
-```
+ - The host handles HTTP response cleanup, so no need to close response objects
+ - Keep plugin instances lightweight as they are created and destroyed frequently
-#### Using Go PDK Packages
+2. **Error Handling**:
-Navidrome provides type-safe Go packages for each capability and host service in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern:
+ - Use the standard error types when appropriate
+ - Return descriptive error messages for debugging
+ - Custom errors are supported and will be propagated to the caller
-```go
-package main
+3. **Performance**:
-import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
+ - Remember plugins are stateless, so don't rely on local variables for caching. Use the CacheService for caching data.
+ - Use efficient algorithms that work well in single-call scenarios
-type myPlugin struct{}
+4. **Security**:
+ - Only request permissions you actually need (see [Plugin Permission System](#plugin-permission-system))
+ - Validate inputs to prevent injection attacks
+ - Don't store sensitive credentials in the plugin code
+ - Use configuration for API keys and sensitive data
-func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
- return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
-}
+## Limitations
-func init() { metadata.Register(&myPlugin{}) }
-func main() {}
-```
+1. WASM plugins have limited access to system resources
+2. Plugin compilation has an initial overhead on first load, as it needs to be compiled to WebAssembly
+ - Subsequent calls are faster due to caching
+3. New plugin capabilities types require changes to the core codebase
+4. Stateless nature prevents certain optimizations
-Add to your `go.mod`:
+## Troubleshooting
-```
-require github.com/navidrome/navidrome v0.0.0
-replace github.com/navidrome/navidrome => ../../..
-```
+1. **Plugin not detected**:
-**Available capability packages:**
+ - Ensure `plugin.wasm` and `manifest.json` exist in the plugin directory
+ - Check that the manifest contains valid capabilities names
+ - Verify the manifest schema is valid (see [Plugin Permission System](#plugin-permission-system))
-| Package | Import Path | Description |
-|-------------------|--------------------------------------|--------------------------------------|
-| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers |
-| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services |
-| `lyrics` | `plugins/pdk/go/lyrics` | Lyrics providers |
-| `sonicsimilarity` | `plugins/pdk/go/sonicsimilarity` | Audio similarity discovery |
-| `taskworker` | `plugins/pdk/go/taskworker` | Background task processing |
-| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization |
-| `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) |
+2. **Permission errors**:
-See the example plugins in [examples/](examples/) for complete usage patterns.
+ - **"function not exported in module env"**: Plugin trying to use a service without proper permission
+ - Check that required permissions are declared in `manifest.json`
+ - See [Troubleshooting Permissions](#troubleshooting-permissions) for detailed guidance
-### Rust
+3. **Compilation errors**:
-```bash
-# Build WebAssembly module
-cargo build --release --target wasm32-wasip1
+ - Check logs for WASM compilation errors
+ - Verify the plugin is compatible with the current API version
-# Package as .ndp
-zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm
-```
-
-#### Using Rust PDK
-
-```toml
-# Cargo.toml
-[dependencies]
-nd-pdk = { path = "../../pdk/rust/nd-pdk" }
-extism-pdk = "1.2"
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
-```
-
-**Implementing capabilities with traits and macros:**
-
-```rust
-use nd_pdk::scrobbler::{Scrobbler, IsAuthorizedRequest, Error};
-use nd_pdk::register_scrobbler;
-
-#[derive(Default)]
-struct MyPlugin;
-
-impl Scrobbler for MyPlugin {
- fn is_authorized(&self, req: IsAuthorizedRequest) -> Result {
- Ok(true)
- }
- fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error> { Ok(()) }
- fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { Ok(()) }
-}
-
-register_scrobbler!(MyPlugin); // Generates all WASM exports
-```
-
-**Using host services:**
-
-```rust
-use nd_pdk::host::{cache, scheduler, library};
-
-cache::set_string("my_key", "my_value", 3600)?;
-scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
-let libs = library::get_all_libraries()?;
-```
-
-See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation.
-
-### Python (with extism-py)
-
-```bash
-# Build WebAssembly module (requires extism-py installed)
-extism-py plugin.wasm -o plugin.wasm *.py
-
-# Package as .ndp
-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/).
-
-### Using XTP CLI (Scaffolding)
-
-Bootstrap a new plugin from a schema:
-
-```bash
-# Install XTP CLI: https://docs.xtp.dylibso.com/docs/cli
-
-# Create a metadata agent plugin
-xtp plugin init \
- --schema-file plugins/capabilities/metadata_agent.yaml \
- --template go \
- --path ./my-agent \
- --name my-agent
-
-# Build and package
-cd my-agent && xtp plugin build
-zip -j my-agent.ndp manifest.json dist/plugin.wasm
-```
-
-See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples.
-
----
-
-## Examples
-
-See [examples/](examples/) for complete working plugins:
-
-| Plugin | Language | Capabilities | Host Services | Description |
-|----------------------------------------------------------------|----------------|---------------|--------------------------------------------|--------------------------------|
-| [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 |
-
----
-
-## Security
-
-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
-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
-7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed
-
----
-
-## Runtime Management
-
-### Auto-Reload
-
-With `AutoReload = true`, Navidrome watches the plugins folder and automatically detects when `.ndp` files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive.
-
-If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes.
-
-### 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.
-
-### Important Notes
-
-- **In-flight requests** – When reloading, existing requests complete before the new version takes over
-- **Config changes** – Changes to the plugin configuration in the UI are applied immediately
-- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded
\ No newline at end of file
+4. **Runtime errors**:
+ - Look for error messages in the Navidrome logs
+ - Add debug logging to your plugin
+ - Check if the error is permission-related before debugging plugin logic
diff --git a/plugins/adapter_media_agent.go b/plugins/adapter_media_agent.go
new file mode 100644
index 000000000..eca891275
--- /dev/null
+++ b/plugins/adapter_media_agent.go
@@ -0,0 +1,166 @@
+package plugins
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/plugins/api"
+ "github.com/tetratelabs/wazero"
+)
+
+// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin
+func newWasmMediaAgent(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
+ loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
+ if err != nil {
+ log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
+ return nil
+ }
+ return &wasmMediaAgent{
+ baseCapability: newBaseCapability[api.MetadataAgent, *api.MetadataAgentPlugin](
+ wasmPath,
+ pluginID,
+ CapabilityMetadataAgent,
+ m.metrics,
+ loader,
+ func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) {
+ return l.Load(ctx, path)
+ },
+ ),
+ }
+}
+
+// wasmMediaAgent adapts a MetadataAgent plugin to implement the agents.Interface
+type wasmMediaAgent struct {
+ *baseCapability[api.MetadataAgent, *api.MetadataAgentPlugin]
+}
+
+func (w *wasmMediaAgent) AgentName() string {
+ return w.id
+}
+
+func (w *wasmMediaAgent) mapError(err error) error {
+ if err != nil && (err.Error() == api.ErrNotFound.Error() || err.Error() == api.ErrNotImplemented.Error()) {
+ return agents.ErrNotFound
+ }
+ return err
+}
+
+// Album-related methods
+
+func (w *wasmMediaAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
+ res, err := callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*api.AlbumInfoResponse, error) {
+ return inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
+ })
+ if err != nil {
+ return nil, w.mapError(err)
+ }
+ if res == nil || res.Info == nil {
+ return nil, agents.ErrNotFound
+ }
+ info := res.Info
+ return &agents.AlbumInfo{
+ Name: info.Name,
+ MBID: info.Mbid,
+ Description: info.Description,
+ URL: info.Url,
+ }, nil
+}
+
+func (w *wasmMediaAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
+ res, err := callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) (*api.AlbumImagesResponse, error) {
+ return inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
+ })
+ if err != nil {
+ return nil, w.mapError(err)
+ }
+ return convertExternalImages(res.Images), nil
+}
+
+// Artist-related methods
+
+func (w *wasmMediaAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
+ res, err := callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (*api.ArtistMBIDResponse, error) {
+ return inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
+ })
+ if err != nil {
+ return "", w.mapError(err)
+ }
+ return res.GetMbid(), nil
+}
+
+func (w *wasmMediaAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
+ res, err := callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (*api.ArtistURLResponse, error) {
+ return inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
+ })
+ if err != nil {
+ return "", w.mapError(err)
+ }
+ return res.GetUrl(), nil
+}
+
+func (w *wasmMediaAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
+ res, err := callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (*api.ArtistBiographyResponse, error) {
+ return inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
+ })
+ if err != nil {
+ return "", w.mapError(err)
+ }
+ return res.GetBiography(), nil
+}
+
+func (w *wasmMediaAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
+ resp, err := callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) (*api.ArtistSimilarResponse, error) {
+ return inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
+ })
+ if err != nil {
+ return nil, w.mapError(err)
+ }
+ artists := make([]agents.Artist, 0, len(resp.GetArtists()))
+ for _, a := range resp.GetArtists() {
+ artists = append(artists, agents.Artist{
+ Name: a.GetName(),
+ MBID: a.GetMbid(),
+ })
+ }
+ return artists, nil
+}
+
+func (w *wasmMediaAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
+ resp, err := callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) (*api.ArtistImageResponse, error) {
+ return inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
+ })
+ if err != nil {
+ return nil, w.mapError(err)
+ }
+ return convertExternalImages(resp.Images), nil
+}
+
+func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
+ resp, err := callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) (*api.ArtistTopSongsResponse, error) {
+ return inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
+ })
+ if err != nil {
+ return nil, w.mapError(err)
+ }
+ songs := make([]agents.Song, 0, len(resp.GetSongs()))
+ for _, s := range resp.GetSongs() {
+ songs = append(songs, agents.Song{
+ Name: s.GetName(),
+ MBID: s.GetMbid(),
+ })
+ }
+ return songs, nil
+}
+
+// Helper function to convert ExternalImage objects from the API to the agents package
+func convertExternalImages(images []*api.ExternalImage) []agents.ExternalImage {
+ result := make([]agents.ExternalImage, 0, len(images))
+ for _, img := range images {
+ result = append(result, agents.ExternalImage{
+ URL: img.GetUrl(),
+ Size: int(img.GetSize()),
+ })
+ }
+ return result
+}
diff --git a/plugins/adapter_media_agent_test.go b/plugins/adapter_media_agent_test.go
new file mode 100644
index 000000000..70b5d275a
--- /dev/null
+++ b/plugins/adapter_media_agent_test.go
@@ -0,0 +1,227 @@
+package plugins
+
+import (
+ "context"
+ "errors"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core/agents"
+ "github.com/navidrome/navidrome/core/metrics"
+ "github.com/navidrome/navidrome/plugins/api"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Adapter Media Agent", func() {
+ var ctx context.Context
+ var mgr *managerImpl
+
+ BeforeEach(func() {
+ ctx = GinkgoT().Context()
+
+ // Ensure plugins folder is set to testdata
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Plugins.Folder = testDataDir
+
+ mgr = createManager(nil, metrics.NewNoopInstance())
+ mgr.ScanPlugins()
+
+ // Wait for all plugins to compile to avoid race conditions
+ err := mgr.EnsureCompiled("multi_plugin")
+ Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully")
+ err = mgr.EnsureCompiled("fake_album_agent")
+ Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully")
+ })
+
+ Describe("AgentName and PluginName", func() {
+ It("should return the plugin name", func() {
+ agent := mgr.LoadPlugin("multi_plugin", "MetadataAgent")
+ Expect(agent).NotTo(BeNil(), "multi_plugin should be loaded")
+ Expect(agent.PluginID()).To(Equal("multi_plugin"))
+ })
+ It("should return the agent name", func() {
+ agent, ok := mgr.LoadMediaAgent("multi_plugin")
+ Expect(ok).To(BeTrue(), "multi_plugin should be loaded as media agent")
+ Expect(agent.AgentName()).To(Equal("multi_plugin"))
+ })
+ })
+
+ Describe("Album methods", func() {
+ var agent *wasmMediaAgent
+
+ BeforeEach(func() {
+ a, ok := mgr.LoadMediaAgent("fake_album_agent")
+ Expect(ok).To(BeTrue(), "fake_album_agent should be loaded")
+ agent = a.(*wasmMediaAgent)
+ })
+
+ Context("GetAlbumInfo", func() {
+ It("should return album information", func() {
+ info, err := agent.GetAlbumInfo(ctx, "Test Album", "Test Artist", "mbid")
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(info).NotTo(BeNil())
+ Expect(info.Name).To(Equal("Test Album"))
+ Expect(info.MBID).To(Equal("album-mbid-123"))
+ Expect(info.Description).To(Equal("This is a test album description"))
+ Expect(info.URL).To(Equal("https://example.com/album"))
+ })
+
+ It("should return ErrNotFound when plugin returns not found", func() {
+ _, err := agent.GetAlbumInfo(ctx, "Test Album", "", "mbid")
+
+ Expect(err).To(Equal(agents.ErrNotFound))
+ })
+
+ It("should return ErrNotFound when plugin returns nil response", func() {
+ _, err := agent.GetAlbumInfo(ctx, "", "", "")
+
+ Expect(err).To(Equal(agents.ErrNotFound))
+ })
+ })
+
+ Context("GetAlbumImages", func() {
+ It("should return album images", func() {
+ images, err := agent.GetAlbumImages(ctx, "Test Album", "Test Artist", "mbid")
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(images).To(Equal([]agents.ExternalImage{
+ {URL: "https://example.com/album1.jpg", Size: 300},
+ {URL: "https://example.com/album2.jpg", Size: 400},
+ }))
+ })
+ })
+ })
+
+ Describe("Artist methods", func() {
+ var agent *wasmMediaAgent
+
+ BeforeEach(func() {
+ a, ok := mgr.LoadMediaAgent("fake_artist_agent")
+ Expect(ok).To(BeTrue(), "fake_artist_agent should be loaded")
+ agent = a.(*wasmMediaAgent)
+ })
+
+ Context("GetArtistMBID", func() {
+ It("should return artist MBID", func() {
+ mbid, err := agent.GetArtistMBID(ctx, "artist-id", "Test Artist")
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(mbid).To(Equal("1234567890"))
+ })
+
+ It("should return ErrNotFound when plugin returns not found", func() {
+ _, err := agent.GetArtistMBID(ctx, "artist-id", "")
+
+ Expect(err).To(Equal(agents.ErrNotFound))
+ })
+ })
+
+ Context("GetArtistURL", func() {
+ It("should return artist URL", func() {
+ url, err := agent.GetArtistURL(ctx, "artist-id", "Test Artist", "mbid")
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(url).To(Equal("https://example.com"))
+ })
+ })
+
+ Context("GetArtistBiography", func() {
+ It("should return artist biography", func() {
+ bio, err := agent.GetArtistBiography(ctx, "artist-id", "Test Artist", "mbid")
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(bio).To(Equal("This is a test biography"))
+ })
+ })
+
+ Context("GetSimilarArtists", func() {
+ It("should return similar artists", func() {
+ artists, err := agent.GetSimilarArtists(ctx, "artist-id", "Test Artist", "mbid", 10)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(artists).To(Equal([]agents.Artist{
+ {Name: "Similar Artist 1", MBID: "mbid1"},
+ {Name: "Similar Artist 2", MBID: "mbid2"},
+ }))
+ })
+ })
+
+ Context("GetArtistImages", func() {
+ It("should return artist images", func() {
+ images, err := agent.GetArtistImages(ctx, "artist-id", "Test Artist", "mbid")
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(images).To(Equal([]agents.ExternalImage{
+ {URL: "https://example.com/image1.jpg", Size: 100},
+ {URL: "https://example.com/image2.jpg", Size: 200},
+ }))
+ })
+ })
+
+ Context("GetArtistTopSongs", func() {
+ It("should return artist top songs", func() {
+ songs, err := agent.GetArtistTopSongs(ctx, "artist-id", "Test Artist", "mbid", 10)
+
+ Expect(err).NotTo(HaveOccurred())
+ Expect(songs).To(Equal([]agents.Song{
+ {Name: "Song 1", MBID: "mbid1"},
+ {Name: "Song 2", MBID: "mbid2"},
+ }))
+ })
+ })
+ })
+
+ Describe("Helper functions", func() {
+ It("convertExternalImages should convert API image objects to agent image objects", func() {
+ apiImages := []*api.ExternalImage{
+ {Url: "https://example.com/image1.jpg", Size: 100},
+ {Url: "https://example.com/image2.jpg", Size: 200},
+ }
+
+ agentImages := convertExternalImages(apiImages)
+ Expect(agentImages).To(HaveLen(2))
+
+ for i, img := range agentImages {
+ Expect(img.URL).To(Equal(apiImages[i].Url))
+ Expect(img.Size).To(Equal(int(apiImages[i].Size)))
+ }
+ })
+
+ It("convertExternalImages should handle empty slice", func() {
+ agentImages := convertExternalImages([]*api.ExternalImage{})
+ Expect(agentImages).To(BeEmpty())
+ })
+
+ It("convertExternalImages should handle nil", func() {
+ agentImages := convertExternalImages(nil)
+ Expect(agentImages).To(BeEmpty())
+ })
+ })
+
+ Describe("Error mapping", func() {
+ var agent wasmMediaAgent
+
+ It("should map API ErrNotFound to agents.ErrNotFound", func() {
+ err := agent.mapError(api.ErrNotFound)
+ Expect(err).To(Equal(agents.ErrNotFound))
+ })
+
+ It("should map API ErrNotImplemented to agents.ErrNotFound", func() {
+ err := agent.mapError(api.ErrNotImplemented)
+ Expect(err).To(Equal(agents.ErrNotFound))
+ })
+
+ It("should pass through other errors", func() {
+ testErr := errors.New("test error")
+ err := agent.mapError(testErr)
+ Expect(err).To(Equal(testErr))
+ })
+
+ It("should handle nil error", func() {
+ err := agent.mapError(nil)
+ Expect(err).To(BeNil())
+ })
+ })
+})
diff --git a/plugins/adapter_scheduler_callback.go b/plugins/adapter_scheduler_callback.go
new file mode 100644
index 000000000..64b7eefff
--- /dev/null
+++ b/plugins/adapter_scheduler_callback.go
@@ -0,0 +1,46 @@
+package plugins
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/plugins/api"
+ "github.com/tetratelabs/wazero"
+)
+
+// newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin
+func newWasmSchedulerCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
+ loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
+ if err != nil {
+ log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err)
+ return nil
+ }
+ return &wasmSchedulerCallback{
+ baseCapability: newBaseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin](
+ wasmPath,
+ pluginID,
+ CapabilitySchedulerCallback,
+ m.metrics,
+ loader,
+ func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) {
+ return l.Load(ctx, path)
+ },
+ ),
+ }
+}
+
+// wasmSchedulerCallback adapts a SchedulerCallback plugin
+type wasmSchedulerCallback struct {
+ *baseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin]
+}
+
+func (w *wasmSchedulerCallback) OnSchedulerCallback(ctx context.Context, scheduleID string, payload []byte, isRecurring bool) error {
+ _, err := callMethod(ctx, w, "OnSchedulerCallback", func(inst api.SchedulerCallback) (*api.SchedulerCallbackResponse, error) {
+ return inst.OnSchedulerCallback(ctx, &api.SchedulerCallbackRequest{
+ ScheduleId: scheduleID,
+ Payload: payload,
+ IsRecurring: isRecurring,
+ })
+ })
+ return err
+}
diff --git a/plugins/adapter_scrobbler.go b/plugins/adapter_scrobbler.go
new file mode 100644
index 000000000..54c6af127
--- /dev/null
+++ b/plugins/adapter_scrobbler.go
@@ -0,0 +1,136 @@
+package plugins
+
+import (
+ "context"
+ "time"
+
+ "github.com/navidrome/navidrome/core/scrobbler"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/plugins/api"
+ "github.com/tetratelabs/wazero"
+)
+
+func newWasmScrobblerPlugin(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
+ loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
+ if err != nil {
+ log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err)
+ return nil
+ }
+ return &wasmScrobblerPlugin{
+ baseCapability: newBaseCapability[api.Scrobbler, *api.ScrobblerPlugin](
+ wasmPath,
+ pluginID,
+ CapabilityScrobbler,
+ m.metrics,
+ loader,
+ func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) {
+ return l.Load(ctx, path)
+ },
+ ),
+ }
+}
+
+type wasmScrobblerPlugin struct {
+ *baseCapability[api.Scrobbler, *api.ScrobblerPlugin]
+}
+
+func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
+ username, _ := request.UsernameFrom(ctx)
+ if username == "" {
+ u, ok := request.UserFrom(ctx)
+ if ok {
+ username = u.UserName
+ }
+ }
+ resp, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (*api.ScrobblerIsAuthorizedResponse, error) {
+ return inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{
+ UserId: userId,
+ Username: username,
+ })
+ })
+ if err != nil {
+ log.Warn("Error calling IsAuthorized", "userId", userId, "pluginID", w.id, err)
+ }
+ return err == nil && resp.Authorized
+}
+
+func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
+ username, _ := request.UsernameFrom(ctx)
+ if username == "" {
+ u, ok := request.UserFrom(ctx)
+ if ok {
+ username = u.UserName
+ }
+ }
+
+ trackInfo := w.toTrackInfo(track, position)
+ _, err := callMethod(ctx, w, "NowPlaying", func(inst api.Scrobbler) (struct{}, error) {
+ resp, err := inst.NowPlaying(ctx, &api.ScrobblerNowPlayingRequest{
+ UserId: userId,
+ Username: username,
+ Track: trackInfo,
+ Timestamp: time.Now().Unix(),
+ })
+ if err != nil {
+ return struct{}{}, err
+ }
+ if resp.Error != "" {
+ return struct{}{}, nil
+ }
+ return struct{}{}, nil
+ })
+ return err
+}
+
+func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
+ username, _ := request.UsernameFrom(ctx)
+ if username == "" {
+ u, ok := request.UserFrom(ctx)
+ if ok {
+ username = u.UserName
+ }
+ }
+ trackInfo := w.toTrackInfo(&s.MediaFile, 0)
+ _, err := callMethod(ctx, w, "Scrobble", func(inst api.Scrobbler) (struct{}, error) {
+ resp, err := inst.Scrobble(ctx, &api.ScrobblerScrobbleRequest{
+ UserId: userId,
+ Username: username,
+ Track: trackInfo,
+ Timestamp: s.TimeStamp.Unix(),
+ })
+ if err != nil {
+ return struct{}{}, err
+ }
+ if resp.Error != "" {
+ return struct{}{}, nil
+ }
+ return struct{}{}, nil
+ })
+ return err
+}
+
+func (w *wasmScrobblerPlugin) toTrackInfo(track *model.MediaFile, position int) *api.TrackInfo {
+ artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
+
+ for _, a := range track.Participants[model.RoleArtist] {
+ artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
+ }
+ albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
+ for _, a := range track.Participants[model.RoleAlbumArtist] {
+ albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
+ }
+ trackInfo := &api.TrackInfo{
+ Id: track.ID,
+ Mbid: track.MbzRecordingID,
+ Name: track.Title,
+ Album: track.Album,
+ AlbumMbid: track.MbzAlbumID,
+ Artists: artists,
+ AlbumArtists: albumArtists,
+ Length: int32(track.Duration),
+ Position: int32(position),
+ }
+ return trackInfo
+}
diff --git a/plugins/adapter_websocket_callback.go b/plugins/adapter_websocket_callback.go
new file mode 100644
index 000000000..83b8dd567
--- /dev/null
+++ b/plugins/adapter_websocket_callback.go
@@ -0,0 +1,35 @@
+package plugins
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/plugins/api"
+ "github.com/tetratelabs/wazero"
+)
+
+// newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin
+func newWasmWebSocketCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
+ loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
+ if err != nil {
+ log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err)
+ return nil
+ }
+ return &wasmWebSocketCallback{
+ baseCapability: newBaseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin](
+ wasmPath,
+ pluginID,
+ CapabilityWebSocketCallback,
+ m.metrics,
+ loader,
+ func(ctx context.Context, l *api.WebSocketCallbackPlugin, path string) (api.WebSocketCallback, error) {
+ return l.Load(ctx, path)
+ },
+ ),
+ }
+}
+
+// wasmWebSocketCallback adapts a WebSocketCallback plugin
+type wasmWebSocketCallback struct {
+ *baseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin]
+}
diff --git a/plugins/api/api.pb.go b/plugins/api/api.pb.go
new file mode 100644
index 000000000..b570d5c61
--- /dev/null
+++ b/plugins/api/api.pb.go
@@ -0,0 +1,1136 @@
+// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
+// versions:
+// protoc-gen-go-plugin v0.1.0
+// protoc v5.29.3
+// source: api/api.proto
+
+package api
+
+import (
+ context "context"
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type ArtistMBIDRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *ArtistMBIDRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistMBIDRequest) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *ArtistMBIDRequest) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+type ArtistMBIDResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Mbid string `protobuf:"bytes,1,opt,name=mbid,proto3" json:"mbid,omitempty"`
+}
+
+func (x *ArtistMBIDResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistMBIDResponse) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+type ArtistURLRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
+ Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"`
+}
+
+func (x *ArtistURLRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistURLRequest) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *ArtistURLRequest) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *ArtistURLRequest) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+type ArtistURLResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
+}
+
+func (x *ArtistURLResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistURLResponse) GetUrl() string {
+ if x != nil {
+ return x.Url
+ }
+ return ""
+}
+
+type ArtistBiographyRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
+ Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"`
+}
+
+func (x *ArtistBiographyRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistBiographyRequest) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *ArtistBiographyRequest) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *ArtistBiographyRequest) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+type ArtistBiographyResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Biography string `protobuf:"bytes,1,opt,name=biography,proto3" json:"biography,omitempty"`
+}
+
+func (x *ArtistBiographyResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistBiographyResponse) GetBiography() string {
+ if x != nil {
+ return x.Biography
+ }
+ return ""
+}
+
+type ArtistSimilarRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
+ Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"`
+ Limit int32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"`
+}
+
+func (x *ArtistSimilarRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistSimilarRequest) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *ArtistSimilarRequest) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *ArtistSimilarRequest) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+func (x *ArtistSimilarRequest) GetLimit() int32 {
+ if x != nil {
+ return x.Limit
+ }
+ return 0
+}
+
+type Artist struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+ Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"`
+}
+
+func (x *Artist) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *Artist) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *Artist) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+type ArtistSimilarResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Artists []*Artist `protobuf:"bytes,1,rep,name=artists,proto3" json:"artists,omitempty"`
+}
+
+func (x *ArtistSimilarResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistSimilarResponse) GetArtists() []*Artist {
+ if x != nil {
+ return x.Artists
+ }
+ return nil
+}
+
+type ArtistImageRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
+ Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"`
+}
+
+func (x *ArtistImageRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistImageRequest) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *ArtistImageRequest) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *ArtistImageRequest) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+type ExternalImage struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
+ Size int32 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"`
+}
+
+func (x *ExternalImage) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ExternalImage) GetUrl() string {
+ if x != nil {
+ return x.Url
+ }
+ return ""
+}
+
+func (x *ExternalImage) GetSize() int32 {
+ if x != nil {
+ return x.Size
+ }
+ return 0
+}
+
+type ArtistImageResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Images []*ExternalImage `protobuf:"bytes,1,rep,name=images,proto3" json:"images,omitempty"`
+}
+
+func (x *ArtistImageResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistImageResponse) GetImages() []*ExternalImage {
+ if x != nil {
+ return x.Images
+ }
+ return nil
+}
+
+type ArtistTopSongsRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ ArtistName string `protobuf:"bytes,2,opt,name=artistName,proto3" json:"artistName,omitempty"`
+ Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"`
+ Count int32 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"`
+}
+
+func (x *ArtistTopSongsRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistTopSongsRequest) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *ArtistTopSongsRequest) GetArtistName() string {
+ if x != nil {
+ return x.ArtistName
+ }
+ return ""
+}
+
+func (x *ArtistTopSongsRequest) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+func (x *ArtistTopSongsRequest) GetCount() int32 {
+ if x != nil {
+ return x.Count
+ }
+ return 0
+}
+
+type Song struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+ Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"`
+}
+
+func (x *Song) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *Song) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *Song) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+type ArtistTopSongsResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Songs []*Song `protobuf:"bytes,1,rep,name=songs,proto3" json:"songs,omitempty"`
+}
+
+func (x *ArtistTopSongsResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ArtistTopSongsResponse) GetSongs() []*Song {
+ if x != nil {
+ return x.Songs
+ }
+ return nil
+}
+
+type AlbumInfoRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+ Artist string `protobuf:"bytes,2,opt,name=artist,proto3" json:"artist,omitempty"`
+ Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"`
+}
+
+func (x *AlbumInfoRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *AlbumInfoRequest) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *AlbumInfoRequest) GetArtist() string {
+ if x != nil {
+ return x.Artist
+ }
+ return ""
+}
+
+func (x *AlbumInfoRequest) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+type AlbumInfo struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+ Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"`
+ Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
+ Url string `protobuf:"bytes,4,opt,name=url,proto3" json:"url,omitempty"`
+}
+
+func (x *AlbumInfo) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *AlbumInfo) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *AlbumInfo) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+func (x *AlbumInfo) GetDescription() string {
+ if x != nil {
+ return x.Description
+ }
+ return ""
+}
+
+func (x *AlbumInfo) GetUrl() string {
+ if x != nil {
+ return x.Url
+ }
+ return ""
+}
+
+type AlbumInfoResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Info *AlbumInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"`
+}
+
+func (x *AlbumInfoResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *AlbumInfoResponse) GetInfo() *AlbumInfo {
+ if x != nil {
+ return x.Info
+ }
+ return nil
+}
+
+type AlbumImagesRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+ Artist string `protobuf:"bytes,2,opt,name=artist,proto3" json:"artist,omitempty"`
+ Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"`
+}
+
+func (x *AlbumImagesRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *AlbumImagesRequest) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *AlbumImagesRequest) GetArtist() string {
+ if x != nil {
+ return x.Artist
+ }
+ return ""
+}
+
+func (x *AlbumImagesRequest) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+type AlbumImagesResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Images []*ExternalImage `protobuf:"bytes,1,rep,name=images,proto3" json:"images,omitempty"`
+}
+
+func (x *AlbumImagesResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *AlbumImagesResponse) GetImages() []*ExternalImage {
+ if x != nil {
+ return x.Images
+ }
+ return nil
+}
+
+type ScrobblerIsAuthorizedRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
+ Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
+}
+
+func (x *ScrobblerIsAuthorizedRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ScrobblerIsAuthorizedRequest) GetUserId() string {
+ if x != nil {
+ return x.UserId
+ }
+ return ""
+}
+
+func (x *ScrobblerIsAuthorizedRequest) GetUsername() string {
+ if x != nil {
+ return x.Username
+ }
+ return ""
+}
+
+type ScrobblerIsAuthorizedResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Authorized bool `protobuf:"varint,1,opt,name=authorized,proto3" json:"authorized,omitempty"`
+ Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
+}
+
+func (x *ScrobblerIsAuthorizedResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ScrobblerIsAuthorizedResponse) GetAuthorized() bool {
+ if x != nil {
+ return x.Authorized
+ }
+ return false
+}
+
+func (x *ScrobblerIsAuthorizedResponse) GetError() string {
+ if x != nil {
+ return x.Error
+ }
+ return ""
+}
+
+type TrackInfo struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"`
+ Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
+ Album string `protobuf:"bytes,4,opt,name=album,proto3" json:"album,omitempty"`
+ AlbumMbid string `protobuf:"bytes,5,opt,name=album_mbid,json=albumMbid,proto3" json:"album_mbid,omitempty"`
+ Artists []*Artist `protobuf:"bytes,6,rep,name=artists,proto3" json:"artists,omitempty"`
+ AlbumArtists []*Artist `protobuf:"bytes,7,rep,name=album_artists,json=albumArtists,proto3" json:"album_artists,omitempty"`
+ Length int32 `protobuf:"varint,8,opt,name=length,proto3" json:"length,omitempty"` // seconds
+ Position int32 `protobuf:"varint,9,opt,name=position,proto3" json:"position,omitempty"` // seconds
+}
+
+func (x *TrackInfo) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *TrackInfo) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *TrackInfo) GetMbid() string {
+ if x != nil {
+ return x.Mbid
+ }
+ return ""
+}
+
+func (x *TrackInfo) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *TrackInfo) GetAlbum() string {
+ if x != nil {
+ return x.Album
+ }
+ return ""
+}
+
+func (x *TrackInfo) GetAlbumMbid() string {
+ if x != nil {
+ return x.AlbumMbid
+ }
+ return ""
+}
+
+func (x *TrackInfo) GetArtists() []*Artist {
+ if x != nil {
+ return x.Artists
+ }
+ return nil
+}
+
+func (x *TrackInfo) GetAlbumArtists() []*Artist {
+ if x != nil {
+ return x.AlbumArtists
+ }
+ return nil
+}
+
+func (x *TrackInfo) GetLength() int32 {
+ if x != nil {
+ return x.Length
+ }
+ return 0
+}
+
+func (x *TrackInfo) GetPosition() int32 {
+ if x != nil {
+ return x.Position
+ }
+ return 0
+}
+
+type ScrobblerNowPlayingRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
+ Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
+ Track *TrackInfo `protobuf:"bytes,3,opt,name=track,proto3" json:"track,omitempty"`
+ Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
+}
+
+func (x *ScrobblerNowPlayingRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ScrobblerNowPlayingRequest) GetUserId() string {
+ if x != nil {
+ return x.UserId
+ }
+ return ""
+}
+
+func (x *ScrobblerNowPlayingRequest) GetUsername() string {
+ if x != nil {
+ return x.Username
+ }
+ return ""
+}
+
+func (x *ScrobblerNowPlayingRequest) GetTrack() *TrackInfo {
+ if x != nil {
+ return x.Track
+ }
+ return nil
+}
+
+func (x *ScrobblerNowPlayingRequest) GetTimestamp() int64 {
+ if x != nil {
+ return x.Timestamp
+ }
+ return 0
+}
+
+type ScrobblerNowPlayingResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
+}
+
+func (x *ScrobblerNowPlayingResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ScrobblerNowPlayingResponse) GetError() string {
+ if x != nil {
+ return x.Error
+ }
+ return ""
+}
+
+type ScrobblerScrobbleRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
+ Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
+ Track *TrackInfo `protobuf:"bytes,3,opt,name=track,proto3" json:"track,omitempty"`
+ Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
+}
+
+func (x *ScrobblerScrobbleRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ScrobblerScrobbleRequest) GetUserId() string {
+ if x != nil {
+ return x.UserId
+ }
+ return ""
+}
+
+func (x *ScrobblerScrobbleRequest) GetUsername() string {
+ if x != nil {
+ return x.Username
+ }
+ return ""
+}
+
+func (x *ScrobblerScrobbleRequest) GetTrack() *TrackInfo {
+ if x != nil {
+ return x.Track
+ }
+ return nil
+}
+
+func (x *ScrobblerScrobbleRequest) GetTimestamp() int64 {
+ if x != nil {
+ return x.Timestamp
+ }
+ return 0
+}
+
+type ScrobblerScrobbleResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
+}
+
+func (x *ScrobblerScrobbleResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *ScrobblerScrobbleResponse) GetError() string {
+ if x != nil {
+ return x.Error
+ }
+ return ""
+}
+
+type SchedulerCallbackRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID of the scheduled job that triggered this callback
+ Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // The data passed when the job was scheduled
+ IsRecurring bool `protobuf:"varint,3,opt,name=is_recurring,json=isRecurring,proto3" json:"is_recurring,omitempty"` // Whether this is from a recurring schedule (cron job)
+}
+
+func (x *SchedulerCallbackRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *SchedulerCallbackRequest) GetScheduleId() string {
+ if x != nil {
+ return x.ScheduleId
+ }
+ return ""
+}
+
+func (x *SchedulerCallbackRequest) GetPayload() []byte {
+ if x != nil {
+ return x.Payload
+ }
+ return nil
+}
+
+func (x *SchedulerCallbackRequest) GetIsRecurring() bool {
+ if x != nil {
+ return x.IsRecurring
+ }
+ return false
+}
+
+type SchedulerCallbackResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` // Error message if the callback failed
+}
+
+func (x *SchedulerCallbackResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *SchedulerCallbackResponse) GetError() string {
+ if x != nil {
+ return x.Error
+ }
+ return ""
+}
+
+type InitRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // Configuration specific to this plugin
+}
+
+func (x *InitRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *InitRequest) GetConfig() map[string]string {
+ if x != nil {
+ return x.Config
+ }
+ return nil
+}
+
+type InitResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` // Error message if initialization failed
+}
+
+func (x *InitResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *InitResponse) GetError() string {
+ if x != nil {
+ return x.Error
+ }
+ return ""
+}
+
+type OnTextMessageRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
+ Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
+}
+
+func (x *OnTextMessageRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *OnTextMessageRequest) GetConnectionId() string {
+ if x != nil {
+ return x.ConnectionId
+ }
+ return ""
+}
+
+func (x *OnTextMessageRequest) GetMessage() string {
+ if x != nil {
+ return x.Message
+ }
+ return ""
+}
+
+type OnTextMessageResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+}
+
+func (x *OnTextMessageResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+type OnBinaryMessageRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
+ Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
+}
+
+func (x *OnBinaryMessageRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *OnBinaryMessageRequest) GetConnectionId() string {
+ if x != nil {
+ return x.ConnectionId
+ }
+ return ""
+}
+
+func (x *OnBinaryMessageRequest) GetData() []byte {
+ if x != nil {
+ return x.Data
+ }
+ return nil
+}
+
+type OnBinaryMessageResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+}
+
+func (x *OnBinaryMessageResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+type OnErrorRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
+ Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
+}
+
+func (x *OnErrorRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *OnErrorRequest) GetConnectionId() string {
+ if x != nil {
+ return x.ConnectionId
+ }
+ return ""
+}
+
+func (x *OnErrorRequest) GetError() string {
+ if x != nil {
+ return x.Error
+ }
+ return ""
+}
+
+type OnErrorResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+}
+
+func (x *OnErrorResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+type OnCloseRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
+ Code int32 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"`
+ Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
+}
+
+func (x *OnCloseRequest) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+func (x *OnCloseRequest) GetConnectionId() string {
+ if x != nil {
+ return x.ConnectionId
+ }
+ return ""
+}
+
+func (x *OnCloseRequest) GetCode() int32 {
+ if x != nil {
+ return x.Code
+ }
+ return 0
+}
+
+func (x *OnCloseRequest) GetReason() string {
+ if x != nil {
+ return x.Reason
+ }
+ return ""
+}
+
+type OnCloseResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+}
+
+func (x *OnCloseResponse) ProtoReflect() protoreflect.Message {
+ panic(`not implemented`)
+}
+
+// go:plugin type=plugin version=1
+type MetadataAgent interface {
+ // Artist metadata methods
+ GetArtistMBID(context.Context, *ArtistMBIDRequest) (*ArtistMBIDResponse, error)
+ GetArtistURL(context.Context, *ArtistURLRequest) (*ArtistURLResponse, error)
+ GetArtistBiography(context.Context, *ArtistBiographyRequest) (*ArtistBiographyResponse, error)
+ GetSimilarArtists(context.Context, *ArtistSimilarRequest) (*ArtistSimilarResponse, error)
+ GetArtistImages(context.Context, *ArtistImageRequest) (*ArtistImageResponse, error)
+ GetArtistTopSongs(context.Context, *ArtistTopSongsRequest) (*ArtistTopSongsResponse, error)
+ // Album metadata methods
+ GetAlbumInfo(context.Context, *AlbumInfoRequest) (*AlbumInfoResponse, error)
+ GetAlbumImages(context.Context, *AlbumImagesRequest) (*AlbumImagesResponse, error)
+}
+
+// go:plugin type=plugin version=1
+type Scrobbler interface {
+ IsAuthorized(context.Context, *ScrobblerIsAuthorizedRequest) (*ScrobblerIsAuthorizedResponse, error)
+ NowPlaying(context.Context, *ScrobblerNowPlayingRequest) (*ScrobblerNowPlayingResponse, error)
+ Scrobble(context.Context, *ScrobblerScrobbleRequest) (*ScrobblerScrobbleResponse, error)
+}
+
+// go:plugin type=plugin version=1
+type SchedulerCallback interface {
+ OnSchedulerCallback(context.Context, *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error)
+}
+
+// go:plugin type=plugin version=1
+type LifecycleManagement interface {
+ OnInit(context.Context, *InitRequest) (*InitResponse, error)
+}
+
+// go:plugin type=plugin version=1
+type WebSocketCallback interface {
+ // Called when a text message is received
+ OnTextMessage(context.Context, *OnTextMessageRequest) (*OnTextMessageResponse, error)
+ // Called when a binary message is received
+ OnBinaryMessage(context.Context, *OnBinaryMessageRequest) (*OnBinaryMessageResponse, error)
+ // Called when an error occurs
+ OnError(context.Context, *OnErrorRequest) (*OnErrorResponse, error)
+ // Called when the connection is closed
+ OnClose(context.Context, *OnCloseRequest) (*OnCloseResponse, error)
+}
diff --git a/plugins/api/api.proto b/plugins/api/api.proto
new file mode 100644
index 000000000..7929ff9e6
--- /dev/null
+++ b/plugins/api/api.proto
@@ -0,0 +1,246 @@
+syntax = "proto3";
+
+package api;
+
+option go_package = "github.com/navidrome/navidrome/plugins/api;api";
+
+// go:plugin type=plugin version=1
+service MetadataAgent {
+ // Artist metadata methods
+ rpc GetArtistMBID(ArtistMBIDRequest) returns (ArtistMBIDResponse);
+ rpc GetArtistURL(ArtistURLRequest) returns (ArtistURLResponse);
+ rpc GetArtistBiography(ArtistBiographyRequest) returns (ArtistBiographyResponse);
+ rpc GetSimilarArtists(ArtistSimilarRequest) returns (ArtistSimilarResponse);
+ rpc GetArtistImages(ArtistImageRequest) returns (ArtistImageResponse);
+ rpc GetArtistTopSongs(ArtistTopSongsRequest) returns (ArtistTopSongsResponse);
+
+ // Album metadata methods
+ rpc GetAlbumInfo(AlbumInfoRequest) returns (AlbumInfoResponse);
+ rpc GetAlbumImages(AlbumImagesRequest) returns (AlbumImagesResponse);
+}
+
+message ArtistMBIDRequest {
+ string id = 1;
+ string name = 2;
+}
+
+message ArtistMBIDResponse {
+ string mbid = 1;
+}
+
+message ArtistURLRequest {
+ string id = 1;
+ string name = 2;
+ string mbid = 3;
+}
+
+message ArtistURLResponse {
+ string url = 1;
+}
+
+message ArtistBiographyRequest {
+ string id = 1;
+ string name = 2;
+ string mbid = 3;
+}
+
+message ArtistBiographyResponse {
+ string biography = 1;
+}
+
+message ArtistSimilarRequest {
+ string id = 1;
+ string name = 2;
+ string mbid = 3;
+ int32 limit = 4;
+}
+
+message Artist {
+ string name = 1;
+ string mbid = 2;
+}
+
+message ArtistSimilarResponse {
+ repeated Artist artists = 1;
+}
+
+message ArtistImageRequest {
+ string id = 1;
+ string name = 2;
+ string mbid = 3;
+}
+
+message ExternalImage {
+ string url = 1;
+ int32 size = 2;
+}
+
+message ArtistImageResponse {
+ repeated ExternalImage images = 1;
+}
+
+message ArtistTopSongsRequest {
+ string id = 1;
+ string artistName = 2;
+ string mbid = 3;
+ int32 count = 4;
+}
+
+message Song {
+ string name = 1;
+ string mbid = 2;
+}
+
+message ArtistTopSongsResponse {
+ repeated Song songs = 1;
+}
+
+message AlbumInfoRequest {
+ string name = 1;
+ string artist = 2;
+ string mbid = 3;
+}
+
+message AlbumInfo {
+ string name = 1;
+ string mbid = 2;
+ string description = 3;
+ string url = 4;
+}
+
+message AlbumInfoResponse {
+ AlbumInfo info = 1;
+}
+
+message AlbumImagesRequest {
+ string name = 1;
+ string artist = 2;
+ string mbid = 3;
+}
+
+message AlbumImagesResponse {
+ repeated ExternalImage images = 1;
+}
+
+// go:plugin type=plugin version=1
+service Scrobbler {
+ rpc IsAuthorized(ScrobblerIsAuthorizedRequest) returns (ScrobblerIsAuthorizedResponse);
+ rpc NowPlaying(ScrobblerNowPlayingRequest) returns (ScrobblerNowPlayingResponse);
+ rpc Scrobble(ScrobblerScrobbleRequest) returns (ScrobblerScrobbleResponse);
+}
+
+message ScrobblerIsAuthorizedRequest {
+ string user_id = 1;
+ string username = 2;
+}
+
+message ScrobblerIsAuthorizedResponse {
+ bool authorized = 1;
+ string error = 2;
+}
+
+message TrackInfo {
+ string id = 1;
+ string mbid = 2;
+ string name = 3;
+ string album = 4;
+ string album_mbid = 5;
+ repeated Artist artists = 6;
+ repeated Artist album_artists = 7;
+ int32 length = 8; // seconds
+ int32 position = 9; // seconds
+}
+
+message ScrobblerNowPlayingRequest {
+ string user_id = 1;
+ string username = 2;
+ TrackInfo track = 3;
+ int64 timestamp = 4;
+}
+
+message ScrobblerNowPlayingResponse {
+ string error = 1;
+}
+
+message ScrobblerScrobbleRequest {
+ string user_id = 1;
+ string username = 2;
+ TrackInfo track = 3;
+ int64 timestamp = 4;
+}
+
+message ScrobblerScrobbleResponse {
+ string error = 1;
+}
+
+// go:plugin type=plugin version=1
+service SchedulerCallback {
+ rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse);
+}
+
+message SchedulerCallbackRequest {
+ string schedule_id = 1; // ID of the scheduled job that triggered this callback
+ bytes payload = 2; // The data passed when the job was scheduled
+ bool is_recurring = 3; // Whether this is from a recurring schedule (cron job)
+}
+
+message SchedulerCallbackResponse {
+ string error = 1; // Error message if the callback failed
+}
+
+// go:plugin type=plugin version=1
+service LifecycleManagement {
+ rpc OnInit(InitRequest) returns (InitResponse);
+}
+
+message InitRequest {
+ map config = 1; // Configuration specific to this plugin
+}
+
+message InitResponse {
+ string error = 1; // Error message if initialization failed
+}
+
+// go:plugin type=plugin version=1
+service WebSocketCallback {
+ // Called when a text message is received
+ rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse);
+
+ // Called when a binary message is received
+ rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse);
+
+ // Called when an error occurs
+ rpc OnError(OnErrorRequest) returns (OnErrorResponse);
+
+ // Called when the connection is closed
+ rpc OnClose(OnCloseRequest) returns (OnCloseResponse);
+}
+
+message OnTextMessageRequest {
+ string connection_id = 1;
+ string message = 2;
+}
+
+message OnTextMessageResponse {}
+
+message OnBinaryMessageRequest {
+ string connection_id = 1;
+ bytes data = 2;
+}
+
+message OnBinaryMessageResponse {}
+
+message OnErrorRequest {
+ string connection_id = 1;
+ string error = 2;
+}
+
+message OnErrorResponse {}
+
+message OnCloseRequest {
+ string connection_id = 1;
+ int32 code = 2;
+ string reason = 3;
+}
+
+message OnCloseResponse {}
\ No newline at end of file
diff --git a/plugins/api/api_host.pb.go b/plugins/api/api_host.pb.go
new file mode 100644
index 000000000..55e648c6c
--- /dev/null
+++ b/plugins/api/api_host.pb.go
@@ -0,0 +1,1688 @@
+//go:build !wasip1
+
+// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
+// versions:
+// protoc-gen-go-plugin v0.1.0
+// protoc v5.29.3
+// source: api/api.proto
+
+package api
+
+import (
+ context "context"
+ errors "errors"
+ fmt "fmt"
+ wazero "github.com/tetratelabs/wazero"
+ api "github.com/tetratelabs/wazero/api"
+ sys "github.com/tetratelabs/wazero/sys"
+ os "os"
+)
+
+const MetadataAgentPluginAPIVersion = 1
+
+type MetadataAgentPlugin struct {
+ newRuntime func(context.Context) (wazero.Runtime, error)
+ moduleConfig wazero.ModuleConfig
+}
+
+func NewMetadataAgentPlugin(ctx context.Context, opts ...wazeroConfigOption) (*MetadataAgentPlugin, error) {
+ o := &WazeroConfig{
+ newRuntime: DefaultWazeroRuntime(),
+ moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"),
+ }
+
+ for _, opt := range opts {
+ opt(o)
+ }
+
+ return &MetadataAgentPlugin{
+ newRuntime: o.newRuntime,
+ moduleConfig: o.moduleConfig,
+ }, nil
+}
+
+type metadataAgent interface {
+ Close(ctx context.Context) error
+ MetadataAgent
+}
+
+func (p *MetadataAgentPlugin) Load(ctx context.Context, pluginPath string) (metadataAgent, error) {
+ b, err := os.ReadFile(pluginPath)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create a new runtime so that multiple modules will not conflict
+ r, err := p.newRuntime(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Compile the WebAssembly module using the default configuration.
+ code, err := r.CompileModule(ctx, b)
+ if err != nil {
+ return nil, err
+ }
+
+ // InstantiateModule runs the "_start" function, WASI's "main".
+ module, err := r.InstantiateModule(ctx, code, p.moduleConfig)
+ if err != nil {
+ // Note: Most compilers do not exit the module after running "_start",
+ // unless there was an Error. This allows you to call exported functions.
+ if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 {
+ return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode())
+ } else if !ok {
+ return nil, err
+ }
+ }
+
+ // Compare API versions with the loading plugin
+ apiVersion := module.ExportedFunction("metadata_agent_api_version")
+ if apiVersion == nil {
+ return nil, errors.New("metadata_agent_api_version is not exported")
+ }
+ results, err := apiVersion.Call(ctx)
+ if err != nil {
+ return nil, err
+ } else if len(results) != 1 {
+ return nil, errors.New("invalid metadata_agent_api_version signature")
+ }
+ if results[0] != MetadataAgentPluginAPIVersion {
+ return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", MetadataAgentPluginAPIVersion, results[0])
+ }
+
+ getartistmbid := module.ExportedFunction("metadata_agent_get_artist_mbid")
+ if getartistmbid == nil {
+ return nil, errors.New("metadata_agent_get_artist_mbid is not exported")
+ }
+ getartisturl := module.ExportedFunction("metadata_agent_get_artist_url")
+ if getartisturl == nil {
+ return nil, errors.New("metadata_agent_get_artist_url is not exported")
+ }
+ getartistbiography := module.ExportedFunction("metadata_agent_get_artist_biography")
+ if getartistbiography == nil {
+ return nil, errors.New("metadata_agent_get_artist_biography is not exported")
+ }
+ getsimilarartists := module.ExportedFunction("metadata_agent_get_similar_artists")
+ if getsimilarartists == nil {
+ return nil, errors.New("metadata_agent_get_similar_artists is not exported")
+ }
+ getartistimages := module.ExportedFunction("metadata_agent_get_artist_images")
+ if getartistimages == nil {
+ return nil, errors.New("metadata_agent_get_artist_images is not exported")
+ }
+ getartisttopsongs := module.ExportedFunction("metadata_agent_get_artist_top_songs")
+ if getartisttopsongs == nil {
+ return nil, errors.New("metadata_agent_get_artist_top_songs is not exported")
+ }
+ getalbuminfo := module.ExportedFunction("metadata_agent_get_album_info")
+ if getalbuminfo == nil {
+ return nil, errors.New("metadata_agent_get_album_info is not exported")
+ }
+ getalbumimages := module.ExportedFunction("metadata_agent_get_album_images")
+ if getalbumimages == nil {
+ return nil, errors.New("metadata_agent_get_album_images is not exported")
+ }
+
+ malloc := module.ExportedFunction("malloc")
+ if malloc == nil {
+ return nil, errors.New("malloc is not exported")
+ }
+
+ free := module.ExportedFunction("free")
+ if free == nil {
+ return nil, errors.New("free is not exported")
+ }
+ return &metadataAgentPlugin{
+ runtime: r,
+ module: module,
+ malloc: malloc,
+ free: free,
+ getartistmbid: getartistmbid,
+ getartisturl: getartisturl,
+ getartistbiography: getartistbiography,
+ getsimilarartists: getsimilarartists,
+ getartistimages: getartistimages,
+ getartisttopsongs: getartisttopsongs,
+ getalbuminfo: getalbuminfo,
+ getalbumimages: getalbumimages,
+ }, nil
+}
+
+func (p *metadataAgentPlugin) Close(ctx context.Context) (err error) {
+ if r := p.runtime; r != nil {
+ r.Close(ctx)
+ }
+ return
+}
+
+type metadataAgentPlugin struct {
+ runtime wazero.Runtime
+ module api.Module
+ malloc api.Function
+ free api.Function
+ getartistmbid api.Function
+ getartisturl api.Function
+ getartistbiography api.Function
+ getsimilarartists api.Function
+ getartistimages api.Function
+ getartisttopsongs api.Function
+ getalbuminfo api.Function
+ getalbumimages api.Function
+}
+
+func (p *metadataAgentPlugin) GetArtistMBID(ctx context.Context, request *ArtistMBIDRequest) (*ArtistMBIDResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.getartistmbid.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(ArtistMBIDResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *metadataAgentPlugin) GetArtistURL(ctx context.Context, request *ArtistURLRequest) (*ArtistURLResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.getartisturl.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(ArtistURLResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *metadataAgentPlugin) GetArtistBiography(ctx context.Context, request *ArtistBiographyRequest) (*ArtistBiographyResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.getartistbiography.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(ArtistBiographyResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *metadataAgentPlugin) GetSimilarArtists(ctx context.Context, request *ArtistSimilarRequest) (*ArtistSimilarResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.getsimilarartists.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(ArtistSimilarResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *metadataAgentPlugin) GetArtistImages(ctx context.Context, request *ArtistImageRequest) (*ArtistImageResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.getartistimages.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(ArtistImageResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *metadataAgentPlugin) GetArtistTopSongs(ctx context.Context, request *ArtistTopSongsRequest) (*ArtistTopSongsResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.getartisttopsongs.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(ArtistTopSongsResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *metadataAgentPlugin) GetAlbumInfo(ctx context.Context, request *AlbumInfoRequest) (*AlbumInfoResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.getalbuminfo.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(AlbumInfoResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *metadataAgentPlugin) GetAlbumImages(ctx context.Context, request *AlbumImagesRequest) (*AlbumImagesResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.getalbumimages.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(AlbumImagesResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+
+const ScrobblerPluginAPIVersion = 1
+
+type ScrobblerPlugin struct {
+ newRuntime func(context.Context) (wazero.Runtime, error)
+ moduleConfig wazero.ModuleConfig
+}
+
+func NewScrobblerPlugin(ctx context.Context, opts ...wazeroConfigOption) (*ScrobblerPlugin, error) {
+ o := &WazeroConfig{
+ newRuntime: DefaultWazeroRuntime(),
+ moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"),
+ }
+
+ for _, opt := range opts {
+ opt(o)
+ }
+
+ return &ScrobblerPlugin{
+ newRuntime: o.newRuntime,
+ moduleConfig: o.moduleConfig,
+ }, nil
+}
+
+type scrobbler interface {
+ Close(ctx context.Context) error
+ Scrobbler
+}
+
+func (p *ScrobblerPlugin) Load(ctx context.Context, pluginPath string) (scrobbler, error) {
+ b, err := os.ReadFile(pluginPath)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create a new runtime so that multiple modules will not conflict
+ r, err := p.newRuntime(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Compile the WebAssembly module using the default configuration.
+ code, err := r.CompileModule(ctx, b)
+ if err != nil {
+ return nil, err
+ }
+
+ // InstantiateModule runs the "_start" function, WASI's "main".
+ module, err := r.InstantiateModule(ctx, code, p.moduleConfig)
+ if err != nil {
+ // Note: Most compilers do not exit the module after running "_start",
+ // unless there was an Error. This allows you to call exported functions.
+ if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 {
+ return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode())
+ } else if !ok {
+ return nil, err
+ }
+ }
+
+ // Compare API versions with the loading plugin
+ apiVersion := module.ExportedFunction("scrobbler_api_version")
+ if apiVersion == nil {
+ return nil, errors.New("scrobbler_api_version is not exported")
+ }
+ results, err := apiVersion.Call(ctx)
+ if err != nil {
+ return nil, err
+ } else if len(results) != 1 {
+ return nil, errors.New("invalid scrobbler_api_version signature")
+ }
+ if results[0] != ScrobblerPluginAPIVersion {
+ return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", ScrobblerPluginAPIVersion, results[0])
+ }
+
+ isauthorized := module.ExportedFunction("scrobbler_is_authorized")
+ if isauthorized == nil {
+ return nil, errors.New("scrobbler_is_authorized is not exported")
+ }
+ nowplaying := module.ExportedFunction("scrobbler_now_playing")
+ if nowplaying == nil {
+ return nil, errors.New("scrobbler_now_playing is not exported")
+ }
+ scrobble := module.ExportedFunction("scrobbler_scrobble")
+ if scrobble == nil {
+ return nil, errors.New("scrobbler_scrobble is not exported")
+ }
+
+ malloc := module.ExportedFunction("malloc")
+ if malloc == nil {
+ return nil, errors.New("malloc is not exported")
+ }
+
+ free := module.ExportedFunction("free")
+ if free == nil {
+ return nil, errors.New("free is not exported")
+ }
+ return &scrobblerPlugin{
+ runtime: r,
+ module: module,
+ malloc: malloc,
+ free: free,
+ isauthorized: isauthorized,
+ nowplaying: nowplaying,
+ scrobble: scrobble,
+ }, nil
+}
+
+func (p *scrobblerPlugin) Close(ctx context.Context) (err error) {
+ if r := p.runtime; r != nil {
+ r.Close(ctx)
+ }
+ return
+}
+
+type scrobblerPlugin struct {
+ runtime wazero.Runtime
+ module api.Module
+ malloc api.Function
+ free api.Function
+ isauthorized api.Function
+ nowplaying api.Function
+ scrobble api.Function
+}
+
+func (p *scrobblerPlugin) IsAuthorized(ctx context.Context, request *ScrobblerIsAuthorizedRequest) (*ScrobblerIsAuthorizedResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.isauthorized.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(ScrobblerIsAuthorizedResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *scrobblerPlugin) NowPlaying(ctx context.Context, request *ScrobblerNowPlayingRequest) (*ScrobblerNowPlayingResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.nowplaying.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(ScrobblerNowPlayingResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *scrobblerPlugin) Scrobble(ctx context.Context, request *ScrobblerScrobbleRequest) (*ScrobblerScrobbleResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.scrobble.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(ScrobblerScrobbleResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+
+const SchedulerCallbackPluginAPIVersion = 1
+
+type SchedulerCallbackPlugin struct {
+ newRuntime func(context.Context) (wazero.Runtime, error)
+ moduleConfig wazero.ModuleConfig
+}
+
+func NewSchedulerCallbackPlugin(ctx context.Context, opts ...wazeroConfigOption) (*SchedulerCallbackPlugin, error) {
+ o := &WazeroConfig{
+ newRuntime: DefaultWazeroRuntime(),
+ moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"),
+ }
+
+ for _, opt := range opts {
+ opt(o)
+ }
+
+ return &SchedulerCallbackPlugin{
+ newRuntime: o.newRuntime,
+ moduleConfig: o.moduleConfig,
+ }, nil
+}
+
+type schedulerCallback interface {
+ Close(ctx context.Context) error
+ SchedulerCallback
+}
+
+func (p *SchedulerCallbackPlugin) Load(ctx context.Context, pluginPath string) (schedulerCallback, error) {
+ b, err := os.ReadFile(pluginPath)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create a new runtime so that multiple modules will not conflict
+ r, err := p.newRuntime(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Compile the WebAssembly module using the default configuration.
+ code, err := r.CompileModule(ctx, b)
+ if err != nil {
+ return nil, err
+ }
+
+ // InstantiateModule runs the "_start" function, WASI's "main".
+ module, err := r.InstantiateModule(ctx, code, p.moduleConfig)
+ if err != nil {
+ // Note: Most compilers do not exit the module after running "_start",
+ // unless there was an Error. This allows you to call exported functions.
+ if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 {
+ return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode())
+ } else if !ok {
+ return nil, err
+ }
+ }
+
+ // Compare API versions with the loading plugin
+ apiVersion := module.ExportedFunction("scheduler_callback_api_version")
+ if apiVersion == nil {
+ return nil, errors.New("scheduler_callback_api_version is not exported")
+ }
+ results, err := apiVersion.Call(ctx)
+ if err != nil {
+ return nil, err
+ } else if len(results) != 1 {
+ return nil, errors.New("invalid scheduler_callback_api_version signature")
+ }
+ if results[0] != SchedulerCallbackPluginAPIVersion {
+ return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", SchedulerCallbackPluginAPIVersion, results[0])
+ }
+
+ onschedulercallback := module.ExportedFunction("scheduler_callback_on_scheduler_callback")
+ if onschedulercallback == nil {
+ return nil, errors.New("scheduler_callback_on_scheduler_callback is not exported")
+ }
+
+ malloc := module.ExportedFunction("malloc")
+ if malloc == nil {
+ return nil, errors.New("malloc is not exported")
+ }
+
+ free := module.ExportedFunction("free")
+ if free == nil {
+ return nil, errors.New("free is not exported")
+ }
+ return &schedulerCallbackPlugin{
+ runtime: r,
+ module: module,
+ malloc: malloc,
+ free: free,
+ onschedulercallback: onschedulercallback,
+ }, nil
+}
+
+func (p *schedulerCallbackPlugin) Close(ctx context.Context) (err error) {
+ if r := p.runtime; r != nil {
+ r.Close(ctx)
+ }
+ return
+}
+
+type schedulerCallbackPlugin struct {
+ runtime wazero.Runtime
+ module api.Module
+ malloc api.Function
+ free api.Function
+ onschedulercallback api.Function
+}
+
+func (p *schedulerCallbackPlugin) OnSchedulerCallback(ctx context.Context, request *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.onschedulercallback.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(SchedulerCallbackResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+
+const LifecycleManagementPluginAPIVersion = 1
+
+type LifecycleManagementPlugin struct {
+ newRuntime func(context.Context) (wazero.Runtime, error)
+ moduleConfig wazero.ModuleConfig
+}
+
+func NewLifecycleManagementPlugin(ctx context.Context, opts ...wazeroConfigOption) (*LifecycleManagementPlugin, error) {
+ o := &WazeroConfig{
+ newRuntime: DefaultWazeroRuntime(),
+ moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"),
+ }
+
+ for _, opt := range opts {
+ opt(o)
+ }
+
+ return &LifecycleManagementPlugin{
+ newRuntime: o.newRuntime,
+ moduleConfig: o.moduleConfig,
+ }, nil
+}
+
+type lifecycleManagement interface {
+ Close(ctx context.Context) error
+ LifecycleManagement
+}
+
+func (p *LifecycleManagementPlugin) Load(ctx context.Context, pluginPath string) (lifecycleManagement, error) {
+ b, err := os.ReadFile(pluginPath)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create a new runtime so that multiple modules will not conflict
+ r, err := p.newRuntime(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Compile the WebAssembly module using the default configuration.
+ code, err := r.CompileModule(ctx, b)
+ if err != nil {
+ return nil, err
+ }
+
+ // InstantiateModule runs the "_start" function, WASI's "main".
+ module, err := r.InstantiateModule(ctx, code, p.moduleConfig)
+ if err != nil {
+ // Note: Most compilers do not exit the module after running "_start",
+ // unless there was an Error. This allows you to call exported functions.
+ if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 {
+ return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode())
+ } else if !ok {
+ return nil, err
+ }
+ }
+
+ // Compare API versions with the loading plugin
+ apiVersion := module.ExportedFunction("lifecycle_management_api_version")
+ if apiVersion == nil {
+ return nil, errors.New("lifecycle_management_api_version is not exported")
+ }
+ results, err := apiVersion.Call(ctx)
+ if err != nil {
+ return nil, err
+ } else if len(results) != 1 {
+ return nil, errors.New("invalid lifecycle_management_api_version signature")
+ }
+ if results[0] != LifecycleManagementPluginAPIVersion {
+ return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", LifecycleManagementPluginAPIVersion, results[0])
+ }
+
+ oninit := module.ExportedFunction("lifecycle_management_on_init")
+ if oninit == nil {
+ return nil, errors.New("lifecycle_management_on_init is not exported")
+ }
+
+ malloc := module.ExportedFunction("malloc")
+ if malloc == nil {
+ return nil, errors.New("malloc is not exported")
+ }
+
+ free := module.ExportedFunction("free")
+ if free == nil {
+ return nil, errors.New("free is not exported")
+ }
+ return &lifecycleManagementPlugin{
+ runtime: r,
+ module: module,
+ malloc: malloc,
+ free: free,
+ oninit: oninit,
+ }, nil
+}
+
+func (p *lifecycleManagementPlugin) Close(ctx context.Context) (err error) {
+ if r := p.runtime; r != nil {
+ r.Close(ctx)
+ }
+ return
+}
+
+type lifecycleManagementPlugin struct {
+ runtime wazero.Runtime
+ module api.Module
+ malloc api.Function
+ free api.Function
+ oninit api.Function
+}
+
+func (p *lifecycleManagementPlugin) OnInit(ctx context.Context, request *InitRequest) (*InitResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.oninit.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(InitResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+
+const WebSocketCallbackPluginAPIVersion = 1
+
+type WebSocketCallbackPlugin struct {
+ newRuntime func(context.Context) (wazero.Runtime, error)
+ moduleConfig wazero.ModuleConfig
+}
+
+func NewWebSocketCallbackPlugin(ctx context.Context, opts ...wazeroConfigOption) (*WebSocketCallbackPlugin, error) {
+ o := &WazeroConfig{
+ newRuntime: DefaultWazeroRuntime(),
+ moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"),
+ }
+
+ for _, opt := range opts {
+ opt(o)
+ }
+
+ return &WebSocketCallbackPlugin{
+ newRuntime: o.newRuntime,
+ moduleConfig: o.moduleConfig,
+ }, nil
+}
+
+type webSocketCallback interface {
+ Close(ctx context.Context) error
+ WebSocketCallback
+}
+
+func (p *WebSocketCallbackPlugin) Load(ctx context.Context, pluginPath string) (webSocketCallback, error) {
+ b, err := os.ReadFile(pluginPath)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create a new runtime so that multiple modules will not conflict
+ r, err := p.newRuntime(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Compile the WebAssembly module using the default configuration.
+ code, err := r.CompileModule(ctx, b)
+ if err != nil {
+ return nil, err
+ }
+
+ // InstantiateModule runs the "_start" function, WASI's "main".
+ module, err := r.InstantiateModule(ctx, code, p.moduleConfig)
+ if err != nil {
+ // Note: Most compilers do not exit the module after running "_start",
+ // unless there was an Error. This allows you to call exported functions.
+ if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 {
+ return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode())
+ } else if !ok {
+ return nil, err
+ }
+ }
+
+ // Compare API versions with the loading plugin
+ apiVersion := module.ExportedFunction("web_socket_callback_api_version")
+ if apiVersion == nil {
+ return nil, errors.New("web_socket_callback_api_version is not exported")
+ }
+ results, err := apiVersion.Call(ctx)
+ if err != nil {
+ return nil, err
+ } else if len(results) != 1 {
+ return nil, errors.New("invalid web_socket_callback_api_version signature")
+ }
+ if results[0] != WebSocketCallbackPluginAPIVersion {
+ return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", WebSocketCallbackPluginAPIVersion, results[0])
+ }
+
+ ontextmessage := module.ExportedFunction("web_socket_callback_on_text_message")
+ if ontextmessage == nil {
+ return nil, errors.New("web_socket_callback_on_text_message is not exported")
+ }
+ onbinarymessage := module.ExportedFunction("web_socket_callback_on_binary_message")
+ if onbinarymessage == nil {
+ return nil, errors.New("web_socket_callback_on_binary_message is not exported")
+ }
+ onerror := module.ExportedFunction("web_socket_callback_on_error")
+ if onerror == nil {
+ return nil, errors.New("web_socket_callback_on_error is not exported")
+ }
+ onclose := module.ExportedFunction("web_socket_callback_on_close")
+ if onclose == nil {
+ return nil, errors.New("web_socket_callback_on_close is not exported")
+ }
+
+ malloc := module.ExportedFunction("malloc")
+ if malloc == nil {
+ return nil, errors.New("malloc is not exported")
+ }
+
+ free := module.ExportedFunction("free")
+ if free == nil {
+ return nil, errors.New("free is not exported")
+ }
+ return &webSocketCallbackPlugin{
+ runtime: r,
+ module: module,
+ malloc: malloc,
+ free: free,
+ ontextmessage: ontextmessage,
+ onbinarymessage: onbinarymessage,
+ onerror: onerror,
+ onclose: onclose,
+ }, nil
+}
+
+func (p *webSocketCallbackPlugin) Close(ctx context.Context) (err error) {
+ if r := p.runtime; r != nil {
+ r.Close(ctx)
+ }
+ return
+}
+
+type webSocketCallbackPlugin struct {
+ runtime wazero.Runtime
+ module api.Module
+ malloc api.Function
+ free api.Function
+ ontextmessage api.Function
+ onbinarymessage api.Function
+ onerror api.Function
+ onclose api.Function
+}
+
+func (p *webSocketCallbackPlugin) OnTextMessage(ctx context.Context, request *OnTextMessageRequest) (*OnTextMessageResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.ontextmessage.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(OnTextMessageResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *webSocketCallbackPlugin) OnBinaryMessage(ctx context.Context, request *OnBinaryMessageRequest) (*OnBinaryMessageResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.onbinarymessage.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(OnBinaryMessageResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *webSocketCallbackPlugin) OnError(ctx context.Context, request *OnErrorRequest) (*OnErrorResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.onerror.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(OnErrorResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+func (p *webSocketCallbackPlugin) OnClose(ctx context.Context, request *OnCloseRequest) (*OnCloseResponse, error) {
+ data, err := request.MarshalVT()
+ if err != nil {
+ return nil, err
+ }
+ dataSize := uint64(len(data))
+
+ var dataPtr uint64
+ // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin.
+ if dataSize != 0 {
+ results, err := p.malloc.Call(ctx, dataSize)
+ if err != nil {
+ return nil, err
+ }
+ dataPtr = results[0]
+ // This pointer is managed by the Wasm module, which is unaware of external usage.
+ // So, we have to free it when finished
+ defer p.free.Call(ctx, dataPtr)
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ if !p.module.Memory().Write(uint32(dataPtr), data) {
+ return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size())
+ }
+ }
+
+ ptrSize, err := p.onclose.Call(ctx, dataPtr, dataSize)
+ if err != nil {
+ return nil, err
+ }
+
+ resPtr := uint32(ptrSize[0] >> 32)
+ resSize := uint32(ptrSize[0])
+ var isErrResponse bool
+ if (resSize & (1 << 31)) > 0 {
+ isErrResponse = true
+ resSize &^= (1 << 31)
+ }
+
+ // We don't need the memory after deserialization: make sure it is freed.
+ if resPtr != 0 {
+ defer p.free.Call(ctx, uint64(resPtr))
+ }
+
+ // The pointer is a linear memory offset, which is where we write the name.
+ bytes, ok := p.module.Memory().Read(resPtr, resSize)
+ if !ok {
+ return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d",
+ resPtr, resSize, p.module.Memory().Size())
+ }
+
+ if isErrResponse {
+ return nil, errors.New(string(bytes))
+ }
+
+ response := new(OnCloseResponse)
+ if err = response.UnmarshalVT(bytes); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
diff --git a/plugins/api/api_options.pb.go b/plugins/api/api_options.pb.go
new file mode 100644
index 000000000..430bf0a5c
--- /dev/null
+++ b/plugins/api/api_options.pb.go
@@ -0,0 +1,47 @@
+//go:build !wasip1
+
+// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
+// versions:
+// protoc-gen-go-plugin v0.1.0
+// protoc v5.29.3
+// source: api/api.proto
+
+package api
+
+import (
+ context "context"
+ wazero "github.com/tetratelabs/wazero"
+ wasi_snapshot_preview1 "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+)
+
+type wazeroConfigOption func(plugin *WazeroConfig)
+
+type WazeroNewRuntime func(context.Context) (wazero.Runtime, error)
+
+type WazeroConfig struct {
+ newRuntime func(context.Context) (wazero.Runtime, error)
+ moduleConfig wazero.ModuleConfig
+}
+
+func WazeroRuntime(newRuntime WazeroNewRuntime) wazeroConfigOption {
+ return func(h *WazeroConfig) {
+ h.newRuntime = newRuntime
+ }
+}
+
+func DefaultWazeroRuntime() WazeroNewRuntime {
+ return func(ctx context.Context) (wazero.Runtime, error) {
+ r := wazero.NewRuntime(ctx)
+ if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
+ return nil, err
+ }
+
+ return r, nil
+ }
+}
+
+func WazeroModuleConfig(moduleConfig wazero.ModuleConfig) wazeroConfigOption {
+ return func(h *WazeroConfig) {
+ h.moduleConfig = moduleConfig
+ }
+}
diff --git a/plugins/api/api_plugin.pb.go b/plugins/api/api_plugin.pb.go
new file mode 100644
index 000000000..0a022be9b
--- /dev/null
+++ b/plugins/api/api_plugin.pb.go
@@ -0,0 +1,487 @@
+//go:build wasip1
+
+// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
+// versions:
+// protoc-gen-go-plugin v0.1.0
+// protoc v5.29.3
+// source: api/api.proto
+
+package api
+
+import (
+ context "context"
+ wasm "github.com/knqyf263/go-plugin/wasm"
+)
+
+const MetadataAgentPluginAPIVersion = 1
+
+//go:wasmexport metadata_agent_api_version
+func _metadata_agent_api_version() uint64 {
+ return MetadataAgentPluginAPIVersion
+}
+
+var metadataAgent MetadataAgent
+
+func RegisterMetadataAgent(p MetadataAgent) {
+ metadataAgent = p
+}
+
+//go:wasmexport metadata_agent_get_artist_mbid
+func _metadata_agent_get_artist_mbid(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(ArtistMBIDRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := metadataAgent.GetArtistMBID(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport metadata_agent_get_artist_url
+func _metadata_agent_get_artist_url(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(ArtistURLRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := metadataAgent.GetArtistURL(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport metadata_agent_get_artist_biography
+func _metadata_agent_get_artist_biography(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(ArtistBiographyRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := metadataAgent.GetArtistBiography(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport metadata_agent_get_similar_artists
+func _metadata_agent_get_similar_artists(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(ArtistSimilarRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := metadataAgent.GetSimilarArtists(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport metadata_agent_get_artist_images
+func _metadata_agent_get_artist_images(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(ArtistImageRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := metadataAgent.GetArtistImages(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport metadata_agent_get_artist_top_songs
+func _metadata_agent_get_artist_top_songs(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(ArtistTopSongsRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := metadataAgent.GetArtistTopSongs(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport metadata_agent_get_album_info
+func _metadata_agent_get_album_info(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(AlbumInfoRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := metadataAgent.GetAlbumInfo(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport metadata_agent_get_album_images
+func _metadata_agent_get_album_images(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(AlbumImagesRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := metadataAgent.GetAlbumImages(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+const ScrobblerPluginAPIVersion = 1
+
+//go:wasmexport scrobbler_api_version
+func _scrobbler_api_version() uint64 {
+ return ScrobblerPluginAPIVersion
+}
+
+var scrobbler Scrobbler
+
+func RegisterScrobbler(p Scrobbler) {
+ scrobbler = p
+}
+
+//go:wasmexport scrobbler_is_authorized
+func _scrobbler_is_authorized(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(ScrobblerIsAuthorizedRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := scrobbler.IsAuthorized(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport scrobbler_now_playing
+func _scrobbler_now_playing(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(ScrobblerNowPlayingRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := scrobbler.NowPlaying(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport scrobbler_scrobble
+func _scrobbler_scrobble(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(ScrobblerScrobbleRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := scrobbler.Scrobble(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+const SchedulerCallbackPluginAPIVersion = 1
+
+//go:wasmexport scheduler_callback_api_version
+func _scheduler_callback_api_version() uint64 {
+ return SchedulerCallbackPluginAPIVersion
+}
+
+var schedulerCallback SchedulerCallback
+
+func RegisterSchedulerCallback(p SchedulerCallback) {
+ schedulerCallback = p
+}
+
+//go:wasmexport scheduler_callback_on_scheduler_callback
+func _scheduler_callback_on_scheduler_callback(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(SchedulerCallbackRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := schedulerCallback.OnSchedulerCallback(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+const LifecycleManagementPluginAPIVersion = 1
+
+//go:wasmexport lifecycle_management_api_version
+func _lifecycle_management_api_version() uint64 {
+ return LifecycleManagementPluginAPIVersion
+}
+
+var lifecycleManagement LifecycleManagement
+
+func RegisterLifecycleManagement(p LifecycleManagement) {
+ lifecycleManagement = p
+}
+
+//go:wasmexport lifecycle_management_on_init
+func _lifecycle_management_on_init(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(InitRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := lifecycleManagement.OnInit(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+const WebSocketCallbackPluginAPIVersion = 1
+
+//go:wasmexport web_socket_callback_api_version
+func _web_socket_callback_api_version() uint64 {
+ return WebSocketCallbackPluginAPIVersion
+}
+
+var webSocketCallback WebSocketCallback
+
+func RegisterWebSocketCallback(p WebSocketCallback) {
+ webSocketCallback = p
+}
+
+//go:wasmexport web_socket_callback_on_text_message
+func _web_socket_callback_on_text_message(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(OnTextMessageRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := webSocketCallback.OnTextMessage(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport web_socket_callback_on_binary_message
+func _web_socket_callback_on_binary_message(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(OnBinaryMessageRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := webSocketCallback.OnBinaryMessage(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport web_socket_callback_on_error
+func _web_socket_callback_on_error(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(OnErrorRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := webSocketCallback.OnError(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
+
+//go:wasmexport web_socket_callback_on_close
+func _web_socket_callback_on_close(ptr, size uint32) uint64 {
+ b := wasm.PtrToByte(ptr, size)
+ req := new(OnCloseRequest)
+ if err := req.UnmarshalVT(b); err != nil {
+ return 0
+ }
+ response, err := webSocketCallback.OnClose(context.Background(), req)
+ if err != nil {
+ ptr, size = wasm.ByteToPtr([]byte(err.Error()))
+ return (uint64(ptr) << uint64(32)) | uint64(size) |
+ // Indicate that this is the error string by setting the 32-th bit, assuming that
+ // no data exceeds 31-bit size (2 GiB).
+ (1 << 31)
+ }
+
+ b, err = response.MarshalVT()
+ if err != nil {
+ return 0
+ }
+ ptr, size = wasm.ByteToPtr(b)
+ return (uint64(ptr) << uint64(32)) | uint64(size)
+}
diff --git a/plugins/api/api_plugin_dev.go b/plugins/api/api_plugin_dev.go
new file mode 100644
index 000000000..ed5a064b2
--- /dev/null
+++ b/plugins/api/api_plugin_dev.go
@@ -0,0 +1,34 @@
+//go:build !wasip1
+
+package api
+
+import "github.com/navidrome/navidrome/plugins/host/scheduler"
+
+// This file exists to provide stubs for the plugin registration functions when building for non-WASM targets.
+// This is useful for testing and development purposes, as it allows you to build and run your plugin code
+// without having to compile it to WASM.
+// In a real-world scenario, you would compile your plugin to WASM and use the generated registration functions.
+
+func RegisterMetadataAgent(MetadataAgent) {
+ panic("not implemented")
+}
+
+func RegisterScrobbler(Scrobbler) {
+ panic("not implemented")
+}
+
+func RegisterSchedulerCallback(SchedulerCallback) {
+ panic("not implemented")
+}
+
+func RegisterLifecycleManagement(LifecycleManagement) {
+ panic("not implemented")
+}
+
+func RegisterWebSocketCallback(WebSocketCallback) {
+ panic("not implemented")
+}
+
+func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService {
+ panic("not implemented")
+}
diff --git a/plugins/api/api_plugin_dev_named_registry.go b/plugins/api/api_plugin_dev_named_registry.go
new file mode 100644
index 000000000..2ddb68779
--- /dev/null
+++ b/plugins/api/api_plugin_dev_named_registry.go
@@ -0,0 +1,94 @@
+//go:build wasip1
+
+package api
+
+import (
+ "context"
+ "strings"
+
+ "github.com/navidrome/navidrome/plugins/host/scheduler"
+)
+
+var callbacks = make(namedCallbacks)
+
+// RegisterNamedSchedulerCallback registers a named scheduler callback. Named callbacks allow multiple callbacks to be registered
+// within the same plugin, and for the schedules to be scoped to the named callback. If you only need a single callback, you can use
+// the default (unnamed) callback registration function, RegisterSchedulerCallback.
+// It returns a scheduler.SchedulerService that can be used to schedule jobs for the named callback.
+//
+// Notes:
+//
+// - You can't mix named and unnamed callbacks within the same plugin.
+// - The name should be unique within the plugin, and it's recommended to use a short, descriptive name.
+// - The name is case-sensitive.
+func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService {
+ callbacks[name] = cb
+ RegisterSchedulerCallback(&callbacks)
+ return &namedSchedulerService{name: name, svc: scheduler.NewSchedulerService()}
+}
+
+const zwsp = string('\u200b')
+
+// namedCallbacks is a map of named scheduler callbacks. The key is the name of the callback, and the value is the callback itself.
+type namedCallbacks map[string]SchedulerCallback
+
+func parseKey(key string) (string, string) {
+ parts := strings.SplitN(key, zwsp, 2)
+ if len(parts) != 2 {
+ return "", ""
+ }
+ return parts[0], parts[1]
+}
+
+func (n *namedCallbacks) OnSchedulerCallback(ctx context.Context, req *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) {
+ name, scheduleId := parseKey(req.ScheduleId)
+ cb, exists := callbacks[name]
+ if !exists {
+ return nil, nil
+ }
+ req.ScheduleId = scheduleId
+ return cb.OnSchedulerCallback(ctx, req)
+}
+
+// namedSchedulerService is a wrapper around the host scheduler service that prefixes the schedule IDs with the
+// callback name. It is returned by RegisterNamedSchedulerCallback, and should be used by the plugin to schedule
+// jobs for the named callback.
+type namedSchedulerService struct {
+ name string
+ cb SchedulerCallback
+ svc scheduler.SchedulerService
+}
+
+func (n *namedSchedulerService) makeKey(id string) string {
+ return n.name + zwsp + id
+}
+
+func (n *namedSchedulerService) mapResponse(resp *scheduler.ScheduleResponse, err error) (*scheduler.ScheduleResponse, error) {
+ if err != nil {
+ return nil, err
+ }
+ _, resp.ScheduleId = parseKey(resp.ScheduleId)
+ return resp, nil
+}
+
+func (n *namedSchedulerService) ScheduleOneTime(ctx context.Context, request *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) {
+ key := n.makeKey(request.ScheduleId)
+ request.ScheduleId = key
+ return n.mapResponse(n.svc.ScheduleOneTime(ctx, request))
+}
+
+func (n *namedSchedulerService) ScheduleRecurring(ctx context.Context, request *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) {
+ key := n.makeKey(request.ScheduleId)
+ request.ScheduleId = key
+ return n.mapResponse(n.svc.ScheduleRecurring(ctx, request))
+}
+
+func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *scheduler.CancelRequest) (*scheduler.CancelResponse, error) {
+ key := n.makeKey(request.ScheduleId)
+ request.ScheduleId = key
+ return n.svc.CancelSchedule(ctx, request)
+}
+
+func (n *namedSchedulerService) TimeNow(ctx context.Context, request *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
+ return n.svc.TimeNow(ctx, request)
+}
diff --git a/plugins/api/api_vtproto.pb.go b/plugins/api/api_vtproto.pb.go
new file mode 100644
index 000000000..11caa1946
--- /dev/null
+++ b/plugins/api/api_vtproto.pb.go
@@ -0,0 +1,7315 @@
+// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
+// versions:
+// protoc-gen-go-plugin v0.1.0
+// protoc v5.29.3
+// source: api/api.proto
+
+package api
+
+import (
+ fmt "fmt"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ io "io"
+ bits "math/bits"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+func (m *ArtistMBIDRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistMBIDRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistMBIDRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Id) > 0 {
+ i -= len(m.Id)
+ copy(dAtA[i:], m.Id)
+ i = encodeVarint(dAtA, i, uint64(len(m.Id)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistMBIDResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistMBIDResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistMBIDResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistURLRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistURLRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistURLRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Id) > 0 {
+ i -= len(m.Id)
+ copy(dAtA[i:], m.Id)
+ i = encodeVarint(dAtA, i, uint64(len(m.Id)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistURLResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistURLResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistURLResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Url) > 0 {
+ i -= len(m.Url)
+ copy(dAtA[i:], m.Url)
+ i = encodeVarint(dAtA, i, uint64(len(m.Url)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistBiographyRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistBiographyRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistBiographyRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Id) > 0 {
+ i -= len(m.Id)
+ copy(dAtA[i:], m.Id)
+ i = encodeVarint(dAtA, i, uint64(len(m.Id)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistBiographyResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistBiographyResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistBiographyResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Biography) > 0 {
+ i -= len(m.Biography)
+ copy(dAtA[i:], m.Biography)
+ i = encodeVarint(dAtA, i, uint64(len(m.Biography)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistSimilarRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistSimilarRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistSimilarRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if m.Limit != 0 {
+ i = encodeVarint(dAtA, i, uint64(m.Limit))
+ i--
+ dAtA[i] = 0x20
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Id) > 0 {
+ i -= len(m.Id)
+ copy(dAtA[i:], m.Id)
+ i = encodeVarint(dAtA, i, uint64(len(m.Id)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *Artist) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *Artist) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *Artist) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistSimilarResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistSimilarResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistSimilarResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Artists) > 0 {
+ for iNdEx := len(m.Artists) - 1; iNdEx >= 0; iNdEx-- {
+ size, err := m.Artists[iNdEx].MarshalToSizedBufferVT(dAtA[:i])
+ if err != nil {
+ return 0, err
+ }
+ i -= size
+ i = encodeVarint(dAtA, i, uint64(size))
+ i--
+ dAtA[i] = 0xa
+ }
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistImageRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistImageRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistImageRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Id) > 0 {
+ i -= len(m.Id)
+ copy(dAtA[i:], m.Id)
+ i = encodeVarint(dAtA, i, uint64(len(m.Id)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ExternalImage) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ExternalImage) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ExternalImage) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if m.Size != 0 {
+ i = encodeVarint(dAtA, i, uint64(m.Size))
+ i--
+ dAtA[i] = 0x10
+ }
+ if len(m.Url) > 0 {
+ i -= len(m.Url)
+ copy(dAtA[i:], m.Url)
+ i = encodeVarint(dAtA, i, uint64(len(m.Url)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistImageResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistImageResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistImageResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Images) > 0 {
+ for iNdEx := len(m.Images) - 1; iNdEx >= 0; iNdEx-- {
+ size, err := m.Images[iNdEx].MarshalToSizedBufferVT(dAtA[:i])
+ if err != nil {
+ return 0, err
+ }
+ i -= size
+ i = encodeVarint(dAtA, i, uint64(size))
+ i--
+ dAtA[i] = 0xa
+ }
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistTopSongsRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistTopSongsRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistTopSongsRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if m.Count != 0 {
+ i = encodeVarint(dAtA, i, uint64(m.Count))
+ i--
+ dAtA[i] = 0x20
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.ArtistName) > 0 {
+ i -= len(m.ArtistName)
+ copy(dAtA[i:], m.ArtistName)
+ i = encodeVarint(dAtA, i, uint64(len(m.ArtistName)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Id) > 0 {
+ i -= len(m.Id)
+ copy(dAtA[i:], m.Id)
+ i = encodeVarint(dAtA, i, uint64(len(m.Id)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *Song) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *Song) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *Song) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ArtistTopSongsResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ArtistTopSongsResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ArtistTopSongsResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Songs) > 0 {
+ for iNdEx := len(m.Songs) - 1; iNdEx >= 0; iNdEx-- {
+ size, err := m.Songs[iNdEx].MarshalToSizedBufferVT(dAtA[:i])
+ if err != nil {
+ return 0, err
+ }
+ i -= size
+ i = encodeVarint(dAtA, i, uint64(size))
+ i--
+ dAtA[i] = 0xa
+ }
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *AlbumInfoRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *AlbumInfoRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *AlbumInfoRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.Artist) > 0 {
+ i -= len(m.Artist)
+ copy(dAtA[i:], m.Artist)
+ i = encodeVarint(dAtA, i, uint64(len(m.Artist)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *AlbumInfo) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *AlbumInfo) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *AlbumInfo) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Url) > 0 {
+ i -= len(m.Url)
+ copy(dAtA[i:], m.Url)
+ i = encodeVarint(dAtA, i, uint64(len(m.Url)))
+ i--
+ dAtA[i] = 0x22
+ }
+ if len(m.Description) > 0 {
+ i -= len(m.Description)
+ copy(dAtA[i:], m.Description)
+ i = encodeVarint(dAtA, i, uint64(len(m.Description)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *AlbumInfoResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *AlbumInfoResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *AlbumInfoResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if m.Info != nil {
+ size, err := m.Info.MarshalToSizedBufferVT(dAtA[:i])
+ if err != nil {
+ return 0, err
+ }
+ i -= size
+ i = encodeVarint(dAtA, i, uint64(size))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *AlbumImagesRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *AlbumImagesRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *AlbumImagesRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.Artist) > 0 {
+ i -= len(m.Artist)
+ copy(dAtA[i:], m.Artist)
+ i = encodeVarint(dAtA, i, uint64(len(m.Artist)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *AlbumImagesResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *AlbumImagesResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *AlbumImagesResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Images) > 0 {
+ for iNdEx := len(m.Images) - 1; iNdEx >= 0; iNdEx-- {
+ size, err := m.Images[iNdEx].MarshalToSizedBufferVT(dAtA[:i])
+ if err != nil {
+ return 0, err
+ }
+ i -= size
+ i = encodeVarint(dAtA, i, uint64(size))
+ i--
+ dAtA[i] = 0xa
+ }
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ScrobblerIsAuthorizedRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ScrobblerIsAuthorizedRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ScrobblerIsAuthorizedRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Username) > 0 {
+ i -= len(m.Username)
+ copy(dAtA[i:], m.Username)
+ i = encodeVarint(dAtA, i, uint64(len(m.Username)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.UserId) > 0 {
+ i -= len(m.UserId)
+ copy(dAtA[i:], m.UserId)
+ i = encodeVarint(dAtA, i, uint64(len(m.UserId)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ScrobblerIsAuthorizedResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ScrobblerIsAuthorizedResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ScrobblerIsAuthorizedResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Error) > 0 {
+ i -= len(m.Error)
+ copy(dAtA[i:], m.Error)
+ i = encodeVarint(dAtA, i, uint64(len(m.Error)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if m.Authorized {
+ i--
+ if m.Authorized {
+ dAtA[i] = 1
+ } else {
+ dAtA[i] = 0
+ }
+ i--
+ dAtA[i] = 0x8
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *TrackInfo) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *TrackInfo) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *TrackInfo) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if m.Position != 0 {
+ i = encodeVarint(dAtA, i, uint64(m.Position))
+ i--
+ dAtA[i] = 0x48
+ }
+ if m.Length != 0 {
+ i = encodeVarint(dAtA, i, uint64(m.Length))
+ i--
+ dAtA[i] = 0x40
+ }
+ if len(m.AlbumArtists) > 0 {
+ for iNdEx := len(m.AlbumArtists) - 1; iNdEx >= 0; iNdEx-- {
+ size, err := m.AlbumArtists[iNdEx].MarshalToSizedBufferVT(dAtA[:i])
+ if err != nil {
+ return 0, err
+ }
+ i -= size
+ i = encodeVarint(dAtA, i, uint64(size))
+ i--
+ dAtA[i] = 0x3a
+ }
+ }
+ if len(m.Artists) > 0 {
+ for iNdEx := len(m.Artists) - 1; iNdEx >= 0; iNdEx-- {
+ size, err := m.Artists[iNdEx].MarshalToSizedBufferVT(dAtA[:i])
+ if err != nil {
+ return 0, err
+ }
+ i -= size
+ i = encodeVarint(dAtA, i, uint64(size))
+ i--
+ dAtA[i] = 0x32
+ }
+ }
+ if len(m.AlbumMbid) > 0 {
+ i -= len(m.AlbumMbid)
+ copy(dAtA[i:], m.AlbumMbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.AlbumMbid)))
+ i--
+ dAtA[i] = 0x2a
+ }
+ if len(m.Album) > 0 {
+ i -= len(m.Album)
+ copy(dAtA[i:], m.Album)
+ i = encodeVarint(dAtA, i, uint64(len(m.Album)))
+ i--
+ dAtA[i] = 0x22
+ }
+ if len(m.Name) > 0 {
+ i -= len(m.Name)
+ copy(dAtA[i:], m.Name)
+ i = encodeVarint(dAtA, i, uint64(len(m.Name)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.Mbid) > 0 {
+ i -= len(m.Mbid)
+ copy(dAtA[i:], m.Mbid)
+ i = encodeVarint(dAtA, i, uint64(len(m.Mbid)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.Id) > 0 {
+ i -= len(m.Id)
+ copy(dAtA[i:], m.Id)
+ i = encodeVarint(dAtA, i, uint64(len(m.Id)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ScrobblerNowPlayingRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ScrobblerNowPlayingRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ScrobblerNowPlayingRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if m.Timestamp != 0 {
+ i = encodeVarint(dAtA, i, uint64(m.Timestamp))
+ i--
+ dAtA[i] = 0x20
+ }
+ if m.Track != nil {
+ size, err := m.Track.MarshalToSizedBufferVT(dAtA[:i])
+ if err != nil {
+ return 0, err
+ }
+ i -= size
+ i = encodeVarint(dAtA, i, uint64(size))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.Username) > 0 {
+ i -= len(m.Username)
+ copy(dAtA[i:], m.Username)
+ i = encodeVarint(dAtA, i, uint64(len(m.Username)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.UserId) > 0 {
+ i -= len(m.UserId)
+ copy(dAtA[i:], m.UserId)
+ i = encodeVarint(dAtA, i, uint64(len(m.UserId)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ScrobblerNowPlayingResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ScrobblerNowPlayingResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ScrobblerNowPlayingResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Error) > 0 {
+ i -= len(m.Error)
+ copy(dAtA[i:], m.Error)
+ i = encodeVarint(dAtA, i, uint64(len(m.Error)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ScrobblerScrobbleRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ScrobblerScrobbleRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ScrobblerScrobbleRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if m.Timestamp != 0 {
+ i = encodeVarint(dAtA, i, uint64(m.Timestamp))
+ i--
+ dAtA[i] = 0x20
+ }
+ if m.Track != nil {
+ size, err := m.Track.MarshalToSizedBufferVT(dAtA[:i])
+ if err != nil {
+ return 0, err
+ }
+ i -= size
+ i = encodeVarint(dAtA, i, uint64(size))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if len(m.Username) > 0 {
+ i -= len(m.Username)
+ copy(dAtA[i:], m.Username)
+ i = encodeVarint(dAtA, i, uint64(len(m.Username)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.UserId) > 0 {
+ i -= len(m.UserId)
+ copy(dAtA[i:], m.UserId)
+ i = encodeVarint(dAtA, i, uint64(len(m.UserId)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *ScrobblerScrobbleResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *ScrobblerScrobbleResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *ScrobblerScrobbleResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Error) > 0 {
+ i -= len(m.Error)
+ copy(dAtA[i:], m.Error)
+ i = encodeVarint(dAtA, i, uint64(len(m.Error)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *SchedulerCallbackRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *SchedulerCallbackRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *SchedulerCallbackRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if m.IsRecurring {
+ i--
+ if m.IsRecurring {
+ dAtA[i] = 1
+ } else {
+ dAtA[i] = 0
+ }
+ i--
+ dAtA[i] = 0x18
+ }
+ if len(m.Payload) > 0 {
+ i -= len(m.Payload)
+ copy(dAtA[i:], m.Payload)
+ i = encodeVarint(dAtA, i, uint64(len(m.Payload)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.ScheduleId) > 0 {
+ i -= len(m.ScheduleId)
+ copy(dAtA[i:], m.ScheduleId)
+ i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *SchedulerCallbackResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *SchedulerCallbackResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *SchedulerCallbackResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Error) > 0 {
+ i -= len(m.Error)
+ copy(dAtA[i:], m.Error)
+ i = encodeVarint(dAtA, i, uint64(len(m.Error)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *InitRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *InitRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *InitRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Config) > 0 {
+ for k := range m.Config {
+ v := m.Config[k]
+ baseI := i
+ i -= len(v)
+ copy(dAtA[i:], v)
+ i = encodeVarint(dAtA, i, uint64(len(v)))
+ i--
+ dAtA[i] = 0x12
+ i -= len(k)
+ copy(dAtA[i:], k)
+ i = encodeVarint(dAtA, i, uint64(len(k)))
+ i--
+ dAtA[i] = 0xa
+ i = encodeVarint(dAtA, i, uint64(baseI-i))
+ i--
+ dAtA[i] = 0xa
+ }
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *InitResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *InitResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *InitResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Error) > 0 {
+ i -= len(m.Error)
+ copy(dAtA[i:], m.Error)
+ i = encodeVarint(dAtA, i, uint64(len(m.Error)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *OnTextMessageRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *OnTextMessageRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *OnTextMessageRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Message) > 0 {
+ i -= len(m.Message)
+ copy(dAtA[i:], m.Message)
+ i = encodeVarint(dAtA, i, uint64(len(m.Message)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.ConnectionId) > 0 {
+ i -= len(m.ConnectionId)
+ copy(dAtA[i:], m.ConnectionId)
+ i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *OnTextMessageResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *OnTextMessageResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *OnTextMessageResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *OnBinaryMessageRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *OnBinaryMessageRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *OnBinaryMessageRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Data) > 0 {
+ i -= len(m.Data)
+ copy(dAtA[i:], m.Data)
+ i = encodeVarint(dAtA, i, uint64(len(m.Data)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.ConnectionId) > 0 {
+ i -= len(m.ConnectionId)
+ copy(dAtA[i:], m.ConnectionId)
+ i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *OnBinaryMessageResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *OnBinaryMessageResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *OnBinaryMessageResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *OnErrorRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *OnErrorRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *OnErrorRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Error) > 0 {
+ i -= len(m.Error)
+ copy(dAtA[i:], m.Error)
+ i = encodeVarint(dAtA, i, uint64(len(m.Error)))
+ i--
+ dAtA[i] = 0x12
+ }
+ if len(m.ConnectionId) > 0 {
+ i -= len(m.ConnectionId)
+ copy(dAtA[i:], m.ConnectionId)
+ i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *OnErrorResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *OnErrorResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *OnErrorResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *OnCloseRequest) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *OnCloseRequest) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *OnCloseRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ if len(m.Reason) > 0 {
+ i -= len(m.Reason)
+ copy(dAtA[i:], m.Reason)
+ i = encodeVarint(dAtA, i, uint64(len(m.Reason)))
+ i--
+ dAtA[i] = 0x1a
+ }
+ if m.Code != 0 {
+ i = encodeVarint(dAtA, i, uint64(m.Code))
+ i--
+ dAtA[i] = 0x10
+ }
+ if len(m.ConnectionId) > 0 {
+ i -= len(m.ConnectionId)
+ copy(dAtA[i:], m.ConnectionId)
+ i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId)))
+ i--
+ dAtA[i] = 0xa
+ }
+ return len(dAtA) - i, nil
+}
+
+func (m *OnCloseResponse) MarshalVT() (dAtA []byte, err error) {
+ if m == nil {
+ return nil, nil
+ }
+ size := m.SizeVT()
+ dAtA = make([]byte, size)
+ n, err := m.MarshalToSizedBufferVT(dAtA[:size])
+ if err != nil {
+ return nil, err
+ }
+ return dAtA[:n], nil
+}
+
+func (m *OnCloseResponse) MarshalToVT(dAtA []byte) (int, error) {
+ size := m.SizeVT()
+ return m.MarshalToSizedBufferVT(dAtA[:size])
+}
+
+func (m *OnCloseResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
+ if m == nil {
+ return 0, nil
+ }
+ i := len(dAtA)
+ _ = i
+ var l int
+ _ = l
+ if m.unknownFields != nil {
+ i -= len(m.unknownFields)
+ copy(dAtA[i:], m.unknownFields)
+ }
+ return len(dAtA) - i, nil
+}
+
+func encodeVarint(dAtA []byte, offset int, v uint64) int {
+ offset -= sov(v)
+ base := offset
+ for v >= 1<<7 {
+ dAtA[offset] = uint8(v&0x7f | 0x80)
+ v >>= 7
+ offset++
+ }
+ dAtA[offset] = uint8(v)
+ return base
+}
+func (m *ArtistMBIDRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Id)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistMBIDResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistURLRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Id)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistURLResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Url)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistBiographyRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Id)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistBiographyResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Biography)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistSimilarRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Id)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ if m.Limit != 0 {
+ n += 1 + sov(uint64(m.Limit))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *Artist) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistSimilarResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ if len(m.Artists) > 0 {
+ for _, e := range m.Artists {
+ l = e.SizeVT()
+ n += 1 + l + sov(uint64(l))
+ }
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistImageRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Id)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ExternalImage) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Url)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ if m.Size != 0 {
+ n += 1 + sov(uint64(m.Size))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistImageResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ if len(m.Images) > 0 {
+ for _, e := range m.Images {
+ l = e.SizeVT()
+ n += 1 + l + sov(uint64(l))
+ }
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistTopSongsRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Id)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.ArtistName)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ if m.Count != 0 {
+ n += 1 + sov(uint64(m.Count))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *Song) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ArtistTopSongsResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ if len(m.Songs) > 0 {
+ for _, e := range m.Songs {
+ l = e.SizeVT()
+ n += 1 + l + sov(uint64(l))
+ }
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *AlbumInfoRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Artist)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *AlbumInfo) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Description)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Url)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *AlbumInfoResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ if m.Info != nil {
+ l = m.Info.SizeVT()
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *AlbumImagesRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Artist)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *AlbumImagesResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ if len(m.Images) > 0 {
+ for _, e := range m.Images {
+ l = e.SizeVT()
+ n += 1 + l + sov(uint64(l))
+ }
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ScrobblerIsAuthorizedRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.UserId)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Username)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ScrobblerIsAuthorizedResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ if m.Authorized {
+ n += 2
+ }
+ l = len(m.Error)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *TrackInfo) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Id)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Mbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Name)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Album)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.AlbumMbid)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ if len(m.Artists) > 0 {
+ for _, e := range m.Artists {
+ l = e.SizeVT()
+ n += 1 + l + sov(uint64(l))
+ }
+ }
+ if len(m.AlbumArtists) > 0 {
+ for _, e := range m.AlbumArtists {
+ l = e.SizeVT()
+ n += 1 + l + sov(uint64(l))
+ }
+ }
+ if m.Length != 0 {
+ n += 1 + sov(uint64(m.Length))
+ }
+ if m.Position != 0 {
+ n += 1 + sov(uint64(m.Position))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ScrobblerNowPlayingRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.UserId)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Username)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ if m.Track != nil {
+ l = m.Track.SizeVT()
+ n += 1 + l + sov(uint64(l))
+ }
+ if m.Timestamp != 0 {
+ n += 1 + sov(uint64(m.Timestamp))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ScrobblerNowPlayingResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Error)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ScrobblerScrobbleRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.UserId)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Username)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ if m.Track != nil {
+ l = m.Track.SizeVT()
+ n += 1 + l + sov(uint64(l))
+ }
+ if m.Timestamp != 0 {
+ n += 1 + sov(uint64(m.Timestamp))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *ScrobblerScrobbleResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Error)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *SchedulerCallbackRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.ScheduleId)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Payload)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ if m.IsRecurring {
+ n += 2
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *SchedulerCallbackResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Error)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *InitRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ if len(m.Config) > 0 {
+ for k, v := range m.Config {
+ _ = k
+ _ = v
+ mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v)))
+ n += mapEntrySize + 1 + sov(uint64(mapEntrySize))
+ }
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *InitResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.Error)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *OnTextMessageRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.ConnectionId)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Message)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *OnTextMessageResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *OnBinaryMessageRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.ConnectionId)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Data)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *OnBinaryMessageResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *OnErrorRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.ConnectionId)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ l = len(m.Error)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *OnErrorResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *OnCloseRequest) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ l = len(m.ConnectionId)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ if m.Code != 0 {
+ n += 1 + sov(uint64(m.Code))
+ }
+ l = len(m.Reason)
+ if l > 0 {
+ n += 1 + l + sov(uint64(l))
+ }
+ n += len(m.unknownFields)
+ return n
+}
+
+func (m *OnCloseResponse) SizeVT() (n int) {
+ if m == nil {
+ return 0
+ }
+ var l int
+ _ = l
+ n += len(m.unknownFields)
+ return n
+}
+
+func sov(x uint64) (n int) {
+ return (bits.Len64(x|1) + 6) / 7
+}
+func soz(x uint64) (n int) {
+ return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
+}
+func (m *ArtistMBIDRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistMBIDRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistMBIDRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Id = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistMBIDResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistMBIDResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistMBIDResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistURLRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistURLRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistURLRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Id = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistURLResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistURLResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistURLResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Url = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistBiographyRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistBiographyRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistBiographyRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Id = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistBiographyResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistBiographyResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistBiographyResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Biography", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Biography = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistSimilarRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistSimilarRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistSimilarRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Id = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 4:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Limit", wireType)
+ }
+ m.Limit = 0
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ m.Limit |= int32(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *Artist) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: Artist: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: Artist: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistSimilarResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistSimilarResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistSimilarResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Artists", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Artists = append(m.Artists, &Artist{})
+ if err := m.Artists[len(m.Artists)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil {
+ return err
+ }
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistImageRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistImageRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistImageRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Id = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ExternalImage) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ExternalImage: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ExternalImage: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Url = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Size", wireType)
+ }
+ m.Size = 0
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ m.Size |= int32(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistImageResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistImageResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistImageResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Images", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Images = append(m.Images, &ExternalImage{})
+ if err := m.Images[len(m.Images)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil {
+ return err
+ }
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistTopSongsRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistTopSongsRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistTopSongsRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Id = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field ArtistName", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.ArtistName = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 4:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Count", wireType)
+ }
+ m.Count = 0
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ m.Count |= int32(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *Song) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: Song: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: Song: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ArtistTopSongsResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ArtistTopSongsResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ArtistTopSongsResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Songs", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Songs = append(m.Songs, &Song{})
+ if err := m.Songs[len(m.Songs)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil {
+ return err
+ }
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *AlbumInfoRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: AlbumInfoRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: AlbumInfoRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Artist", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Artist = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *AlbumInfo) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: AlbumInfo: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: AlbumInfo: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Description", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Description = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 4:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Url = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *AlbumInfoResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: AlbumInfoResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: AlbumInfoResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Info", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ if m.Info == nil {
+ m.Info = &AlbumInfo{}
+ }
+ if err := m.Info.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil {
+ return err
+ }
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *AlbumImagesRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: AlbumImagesRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: AlbumImagesRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Artist", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Artist = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *AlbumImagesResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: AlbumImagesResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: AlbumImagesResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Images", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Images = append(m.Images, &ExternalImage{})
+ if err := m.Images[len(m.Images)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil {
+ return err
+ }
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ScrobblerIsAuthorizedRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ScrobblerIsAuthorizedRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ScrobblerIsAuthorizedRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field UserId", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.UserId = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Username", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Username = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ScrobblerIsAuthorizedResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ScrobblerIsAuthorizedResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ScrobblerIsAuthorizedResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Authorized", wireType)
+ }
+ var v int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ v |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ m.Authorized = bool(v != 0)
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Error = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *TrackInfo) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: TrackInfo: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: TrackInfo: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Id = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Mbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Name = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 4:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Album", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Album = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 5:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field AlbumMbid", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.AlbumMbid = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 6:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Artists", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Artists = append(m.Artists, &Artist{})
+ if err := m.Artists[len(m.Artists)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil {
+ return err
+ }
+ iNdEx = postIndex
+ case 7:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field AlbumArtists", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.AlbumArtists = append(m.AlbumArtists, &Artist{})
+ if err := m.AlbumArtists[len(m.AlbumArtists)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil {
+ return err
+ }
+ iNdEx = postIndex
+ case 8:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Length", wireType)
+ }
+ m.Length = 0
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ m.Length |= int32(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ case 9:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Position", wireType)
+ }
+ m.Position = 0
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ m.Position |= int32(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ScrobblerNowPlayingRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ScrobblerNowPlayingRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ScrobblerNowPlayingRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field UserId", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.UserId = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Username", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Username = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Track", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ if m.Track == nil {
+ m.Track = &TrackInfo{}
+ }
+ if err := m.Track.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil {
+ return err
+ }
+ iNdEx = postIndex
+ case 4:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Timestamp", wireType)
+ }
+ m.Timestamp = 0
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ m.Timestamp |= int64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ScrobblerNowPlayingResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ScrobblerNowPlayingResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ScrobblerNowPlayingResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Error = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ScrobblerScrobbleRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ScrobblerScrobbleRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ScrobblerScrobbleRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field UserId", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.UserId = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Username", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Username = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Track", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ if m.Track == nil {
+ m.Track = &TrackInfo{}
+ }
+ if err := m.Track.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil {
+ return err
+ }
+ iNdEx = postIndex
+ case 4:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Timestamp", wireType)
+ }
+ m.Timestamp = 0
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ m.Timestamp |= int64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *ScrobblerScrobbleResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: ScrobblerScrobbleResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: ScrobblerScrobbleResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Error = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *SchedulerCallbackRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: SchedulerCallbackRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: SchedulerCallbackRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.ScheduleId = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Payload", wireType)
+ }
+ var byteLen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ byteLen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if byteLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + byteLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Payload = append(m.Payload[:0], dAtA[iNdEx:postIndex]...)
+ if m.Payload == nil {
+ m.Payload = []byte{}
+ }
+ iNdEx = postIndex
+ case 3:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field IsRecurring", wireType)
+ }
+ var v int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ v |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ m.IsRecurring = bool(v != 0)
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *SchedulerCallbackResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: SchedulerCallbackResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: SchedulerCallbackResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Error = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *InitRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: InitRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: InitRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Config", wireType)
+ }
+ var msglen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ msglen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if msglen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + msglen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ if m.Config == nil {
+ m.Config = make(map[string]string)
+ }
+ var mapkey string
+ var mapvalue string
+ for iNdEx < postIndex {
+ entryPreIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ if fieldNum == 1 {
+ var stringLenmapkey uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLenmapkey |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLenmapkey := int(stringLenmapkey)
+ if intStringLenmapkey < 0 {
+ return ErrInvalidLength
+ }
+ postStringIndexmapkey := iNdEx + intStringLenmapkey
+ if postStringIndexmapkey < 0 {
+ return ErrInvalidLength
+ }
+ if postStringIndexmapkey > l {
+ return io.ErrUnexpectedEOF
+ }
+ mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
+ iNdEx = postStringIndexmapkey
+ } else if fieldNum == 2 {
+ var stringLenmapvalue uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLenmapvalue |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLenmapvalue := int(stringLenmapvalue)
+ if intStringLenmapvalue < 0 {
+ return ErrInvalidLength
+ }
+ postStringIndexmapvalue := iNdEx + intStringLenmapvalue
+ if postStringIndexmapvalue < 0 {
+ return ErrInvalidLength
+ }
+ if postStringIndexmapvalue > l {
+ return io.ErrUnexpectedEOF
+ }
+ mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue])
+ iNdEx = postStringIndexmapvalue
+ } else {
+ iNdEx = entryPreIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > postIndex {
+ return io.ErrUnexpectedEOF
+ }
+ iNdEx += skippy
+ }
+ }
+ m.Config[mapkey] = mapvalue
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *InitResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: InitResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: InitResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Error = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *OnTextMessageRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: OnTextMessageRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: OnTextMessageRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.ConnectionId = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Message", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Message = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *OnTextMessageResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: OnTextMessageResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: OnTextMessageResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *OnBinaryMessageRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: OnBinaryMessageRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: OnBinaryMessageRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.ConnectionId = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Data", wireType)
+ }
+ var byteLen int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ byteLen |= int(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if byteLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + byteLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Data = append(m.Data[:0], dAtA[iNdEx:postIndex]...)
+ if m.Data == nil {
+ m.Data = []byte{}
+ }
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *OnBinaryMessageResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: OnBinaryMessageResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: OnBinaryMessageResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *OnErrorRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: OnErrorRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: OnErrorRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.ConnectionId = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Error = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *OnErrorResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: OnErrorResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: OnErrorResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *OnCloseRequest) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: OnCloseRequest: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: OnCloseRequest: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ case 1:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.ConnectionId = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ case 2:
+ if wireType != 0 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Code", wireType)
+ }
+ m.Code = 0
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ m.Code |= int32(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ case 3:
+ if wireType != 2 {
+ return fmt.Errorf("proto: wrong wireType = %d for field Reason", wireType)
+ }
+ var stringLen uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ stringLen |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ intStringLen := int(stringLen)
+ if intStringLen < 0 {
+ return ErrInvalidLength
+ }
+ postIndex := iNdEx + intStringLen
+ if postIndex < 0 {
+ return ErrInvalidLength
+ }
+ if postIndex > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.Reason = string(dAtA[iNdEx:postIndex])
+ iNdEx = postIndex
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+func (m *OnCloseResponse) UnmarshalVT(dAtA []byte) error {
+ l := len(dAtA)
+ iNdEx := 0
+ for iNdEx < l {
+ preIndex := iNdEx
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= uint64(b&0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ fieldNum := int32(wire >> 3)
+ wireType := int(wire & 0x7)
+ if wireType == 4 {
+ return fmt.Errorf("proto: OnCloseResponse: wiretype end group for non-group")
+ }
+ if fieldNum <= 0 {
+ return fmt.Errorf("proto: OnCloseResponse: illegal tag %d (wire type %d)", fieldNum, wire)
+ }
+ switch fieldNum {
+ default:
+ iNdEx = preIndex
+ skippy, err := skip(dAtA[iNdEx:])
+ if err != nil {
+ return err
+ }
+ if (skippy < 0) || (iNdEx+skippy) < 0 {
+ return ErrInvalidLength
+ }
+ if (iNdEx + skippy) > l {
+ return io.ErrUnexpectedEOF
+ }
+ m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
+ iNdEx += skippy
+ }
+ }
+
+ if iNdEx > l {
+ return io.ErrUnexpectedEOF
+ }
+ return nil
+}
+
+func skip(dAtA []byte) (n int, err error) {
+ l := len(dAtA)
+ iNdEx := 0
+ depth := 0
+ for iNdEx < l {
+ var wire uint64
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return 0, ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return 0, io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ wire |= (uint64(b) & 0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ wireType := int(wire & 0x7)
+ switch wireType {
+ case 0:
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return 0, ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return 0, io.ErrUnexpectedEOF
+ }
+ iNdEx++
+ if dAtA[iNdEx-1] < 0x80 {
+ break
+ }
+ }
+ case 1:
+ iNdEx += 8
+ case 2:
+ var length int
+ for shift := uint(0); ; shift += 7 {
+ if shift >= 64 {
+ return 0, ErrIntOverflow
+ }
+ if iNdEx >= l {
+ return 0, io.ErrUnexpectedEOF
+ }
+ b := dAtA[iNdEx]
+ iNdEx++
+ length |= (int(b) & 0x7F) << shift
+ if b < 0x80 {
+ break
+ }
+ }
+ if length < 0 {
+ return 0, ErrInvalidLength
+ }
+ iNdEx += length
+ case 3:
+ depth++
+ case 4:
+ if depth == 0 {
+ return 0, ErrUnexpectedEndOfGroup
+ }
+ depth--
+ case 5:
+ iNdEx += 4
+ default:
+ return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
+ }
+ if iNdEx < 0 {
+ return 0, ErrInvalidLength
+ }
+ if depth == 0 {
+ return iNdEx, nil
+ }
+ }
+ return 0, io.ErrUnexpectedEOF
+}
+
+var (
+ ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
+ ErrIntOverflow = fmt.Errorf("proto: integer overflow")
+ ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
+)
diff --git a/plugins/api/errors.go b/plugins/api/errors.go
new file mode 100644
index 000000000..796774b15
--- /dev/null
+++ b/plugins/api/errors.go
@@ -0,0 +1,12 @@
+package api
+
+import "errors"
+
+var (
+ // ErrNotImplemented indicates that the plugin does not implement the requested method.
+ // No logic should be executed by the plugin.
+ ErrNotImplemented = errors.New("plugin:not_implemented")
+
+ // ErrNotFound indicates that the requested resource was not found by the plugin.
+ ErrNotFound = errors.New("plugin:not_found")
+)
diff --git a/plugins/base_capability.go b/plugins/base_capability.go
new file mode 100644
index 000000000..6572a25ec
--- /dev/null
+++ b/plugins/base_capability.go
@@ -0,0 +1,159 @@
+package plugins
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/navidrome/navidrome/core/metrics"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/navidrome/navidrome/plugins/api"
+)
+
+// newBaseCapability creates a new instance of baseCapability with the required parameters.
+func newBaseCapability[S any, P any](wasmPath, id, capability string, m metrics.Metrics, loader P, loadFunc loaderFunc[S, P]) *baseCapability[S, P] {
+ return &baseCapability[S, P]{
+ wasmPath: wasmPath,
+ id: id,
+ capability: capability,
+ loader: loader,
+ loadFunc: loadFunc,
+ metrics: m,
+ }
+}
+
+// LoaderFunc is a generic function type that loads a plugin instance.
+type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error)
+
+// baseCapability is a generic base implementation for WASM plugins.
+// S is the capability interface type and P is the plugin loader type.
+type baseCapability[S any, P any] struct {
+ wasmPath string
+ id string
+ capability string
+ loader P
+ loadFunc loaderFunc[S, P]
+ metrics metrics.Metrics
+}
+
+func (w *baseCapability[S, P]) PluginID() string {
+ return w.id
+}
+
+func (w *baseCapability[S, P]) serviceName() string {
+ return w.id + "_" + w.capability
+}
+
+func (w *baseCapability[S, P]) getMetrics() metrics.Metrics {
+ return w.metrics
+}
+
+// getInstance loads a new plugin instance and returns a cleanup function.
+func (w *baseCapability[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) {
+ start := time.Now()
+ // Add context metadata for tracing
+ ctx = log.NewContext(ctx, "capability", w.serviceName(), "method", methodName)
+
+ inst, err := w.loadFunc(ctx, w.loader, w.wasmPath)
+ if err != nil {
+ var zero S
+ return zero, func() {}, fmt.Errorf("baseCapability: failed to load instance for %s: %w", w.serviceName(), err)
+ }
+ // Add context metadata for tracing
+ ctx = log.NewContext(ctx, "instanceID", getInstanceID(inst))
+ log.Trace(ctx, "baseCapability: loaded instance", "elapsed", time.Since(start))
+ return inst, func() {
+ log.Trace(ctx, "baseCapability: finished using instance", "elapsed", time.Since(start))
+ if closer, ok := any(inst).(interface{ Close(context.Context) error }); ok {
+ _ = closer.Close(ctx)
+ }
+ }, nil
+}
+
+type wasmPlugin[S any] interface {
+ PluginID() string
+ getInstance(ctx context.Context, methodName string) (S, func(), error)
+ getMetrics() metrics.Metrics
+}
+
+func callMethod[S any, R any](ctx context.Context, wp WasmPlugin, methodName string, fn func(inst S) (R, error)) (R, error) {
+ // Add a unique call ID to the context for tracing
+ ctx = log.NewContext(ctx, "callID", id.NewRandom())
+ var r R
+
+ p, ok := wp.(wasmPlugin[S])
+ if !ok {
+ log.Error(ctx, "callMethod: not a wasm plugin", "method", methodName, "pluginID", wp.PluginID())
+ return r, fmt.Errorf("wasm plugin: not a wasm plugin: %s", wp.PluginID())
+ }
+
+ inst, done, err := p.getInstance(ctx, methodName)
+ if err != nil {
+ return r, err
+ }
+ start := time.Now()
+ defer done()
+ r, err = checkErr(fn(inst))
+ elapsed := time.Since(start)
+
+ if !errors.Is(err, api.ErrNotImplemented) {
+ id := p.PluginID()
+ isOk := err == nil
+ metrics := p.getMetrics()
+ if metrics != nil {
+ metrics.RecordPluginRequest(ctx, id, methodName, isOk, elapsed.Milliseconds())
+ log.Trace(ctx, "callMethod: sending metrics", "plugin", id, "method", methodName, "ok", isOk, "elapsed", elapsed)
+ }
+ }
+
+ return r, err
+}
+
+// errorResponse is an interface that defines a method to retrieve an error message.
+// It is automatically implemented (generated) by all plugin responses that have an Error field
+type errorResponse interface {
+ GetError() string
+}
+
+// checkErr returns an updated error if the response implements errorResponse and contains an error message.
+// If the response is nil, it returns the original error. Otherwise, it wraps or creates an error as needed.
+// It also maps error strings to their corresponding api.Err* constants.
+func checkErr[T any](resp T, err error) (T, error) {
+ if any(resp) == nil {
+ return resp, mapAPIError(err)
+ }
+ respErr, ok := any(resp).(errorResponse)
+ if ok && respErr.GetError() != "" {
+ respErrMsg := respErr.GetError()
+ respErrErr := errors.New(respErrMsg)
+ mappedErr := mapAPIError(respErrErr)
+ // Check if the error was mapped to an API error (different from the temp error)
+ if errors.Is(mappedErr, api.ErrNotImplemented) || errors.Is(mappedErr, api.ErrNotFound) {
+ // Return the mapped API error instead of wrapping
+ return resp, mappedErr
+ }
+ // For non-API errors, use wrap the original error if it is not nil
+ return resp, errors.Join(respErrErr, err)
+ }
+ return resp, mapAPIError(err)
+}
+
+// mapAPIError maps error strings to their corresponding api.Err* constants.
+// This is needed as errors from plugins may not be of type api.Error, due to serialization/deserialization.
+func mapAPIError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ errStr := err.Error()
+ switch errStr {
+ case api.ErrNotImplemented.Error():
+ return api.ErrNotImplemented
+ case api.ErrNotFound.Error():
+ return api.ErrNotFound
+ default:
+ return err
+ }
+}
diff --git a/plugins/base_capability_test.go b/plugins/base_capability_test.go
new file mode 100644
index 000000000..3bece8dcd
--- /dev/null
+++ b/plugins/base_capability_test.go
@@ -0,0 +1,285 @@
+package plugins
+
+import (
+ "context"
+ "errors"
+
+ "github.com/navidrome/navidrome/plugins/api"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+type nilInstance struct{}
+
+var _ = Describe("baseCapability", func() {
+ var ctx = context.Background()
+
+ It("should load instance using loadFunc", func() {
+ called := false
+ plugin := &baseCapability[*nilInstance, any]{
+ wasmPath: "",
+ id: "test",
+ capability: "test",
+ loadFunc: func(ctx context.Context, _ any, path string) (*nilInstance, error) {
+ called = true
+ return &nilInstance{}, nil
+ },
+ }
+ inst, done, err := plugin.getInstance(ctx, "test")
+ defer done()
+ Expect(err).To(BeNil())
+ Expect(inst).ToNot(BeNil())
+ Expect(called).To(BeTrue())
+ })
+})
+
+var _ = Describe("checkErr", func() {
+ Context("when resp is nil", func() {
+ It("should return nil error when both resp and err are nil", func() {
+ var resp *testErrorResponse
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(BeNil())
+ Expect(err).To(BeNil())
+ })
+
+ It("should return original error unchanged for non-API errors", func() {
+ var resp *testErrorResponse
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(BeNil())
+ Expect(err).To(Equal(originalErr))
+ })
+
+ It("should return mapped API error for ErrNotImplemented", func() {
+ var resp *testErrorResponse
+ err := errors.New("plugin:not_implemented")
+
+ result, mappedErr := checkErr(resp, err)
+
+ Expect(result).To(BeNil())
+ Expect(mappedErr).To(Equal(api.ErrNotImplemented))
+ })
+
+ It("should return mapped API error for ErrNotFound", func() {
+ var resp *testErrorResponse
+ err := errors.New("plugin:not_found")
+
+ result, mappedErr := checkErr(resp, err)
+
+ Expect(result).To(BeNil())
+ Expect(mappedErr).To(Equal(api.ErrNotFound))
+ })
+ })
+
+ Context("when resp is a typed nil that implements errorResponse", func() {
+ It("should not panic and return original error", func() {
+ var resp *testErrorResponse // typed nil
+ originalErr := errors.New("original error")
+
+ // This should not panic
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(BeNil())
+ Expect(err).To(Equal(originalErr))
+ })
+
+ It("should handle typed nil with nil error gracefully", func() {
+ var resp *testErrorResponse // typed nil
+
+ // This should not panic
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(BeNil())
+ Expect(err).To(BeNil())
+ })
+ })
+
+ Context("when resp implements errorResponse with non-empty error", func() {
+ It("should create new error when original error is nil", func() {
+ resp := &testErrorResponse{errorMsg: "plugin error"}
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError("plugin error"))
+ })
+
+ It("should wrap original error when both exist", func() {
+ resp := &testErrorResponse{errorMsg: "plugin error"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(HaveOccurred())
+ // Check that both error messages are present in the joined error
+ errStr := err.Error()
+ Expect(errStr).To(ContainSubstring("plugin error"))
+ Expect(errStr).To(ContainSubstring("original error"))
+ })
+
+ It("should return mapped API error for ErrNotImplemented when no original error", func() {
+ resp := &testErrorResponse{errorMsg: "plugin:not_implemented"}
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotImplemented))
+ })
+
+ It("should return mapped API error for ErrNotFound when no original error", func() {
+ resp := &testErrorResponse{errorMsg: "plugin:not_found"}
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotFound))
+ })
+
+ It("should return mapped API error for ErrNotImplemented even with original error", func() {
+ resp := &testErrorResponse{errorMsg: "plugin:not_implemented"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotImplemented))
+ })
+
+ It("should return mapped API error for ErrNotFound even with original error", func() {
+ resp := &testErrorResponse{errorMsg: "plugin:not_found"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotFound))
+ })
+ })
+
+ Context("when resp implements errorResponse with empty error", func() {
+ It("should return original error unchanged", func() {
+ resp := &testErrorResponse{errorMsg: ""}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(originalErr))
+ })
+
+ It("should return nil error when both are empty/nil", func() {
+ resp := &testErrorResponse{errorMsg: ""}
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(BeNil())
+ })
+
+ It("should map original API error when response error is empty", func() {
+ resp := &testErrorResponse{errorMsg: ""}
+ originalErr := errors.New("plugin:not_implemented")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotImplemented))
+ })
+ })
+
+ Context("when resp does not implement errorResponse", func() {
+ It("should return original error unchanged", func() {
+ resp := &testNonErrorResponse{data: "some data"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(Equal(originalErr))
+ })
+
+ It("should return nil error when original error is nil", func() {
+ resp := &testNonErrorResponse{data: "some data"}
+
+ result, err := checkErr(resp, nil)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(BeNil())
+ })
+
+ It("should map original API error when response doesn't implement errorResponse", func() {
+ resp := &testNonErrorResponse{data: "some data"}
+ originalErr := errors.New("plugin:not_found")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotFound))
+ })
+ })
+
+ Context("when resp is a value type (not pointer)", func() {
+ It("should handle value types that implement errorResponse", func() {
+ resp := testValueErrorResponse{errorMsg: "value error"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(HaveOccurred())
+ // Check that both error messages are present in the joined error
+ errStr := err.Error()
+ Expect(errStr).To(ContainSubstring("value error"))
+ Expect(errStr).To(ContainSubstring("original error"))
+ })
+
+ It("should handle value types with empty error", func() {
+ resp := testValueErrorResponse{errorMsg: ""}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(originalErr))
+ })
+
+ It("should handle value types with API error", func() {
+ resp := testValueErrorResponse{errorMsg: "plugin:not_implemented"}
+ originalErr := errors.New("original error")
+
+ result, err := checkErr(resp, originalErr)
+
+ Expect(result).To(Equal(resp))
+ Expect(err).To(MatchError(api.ErrNotImplemented))
+ })
+ })
+})
+
+// Test helper types
+type testErrorResponse struct {
+ errorMsg string
+}
+
+func (t *testErrorResponse) GetError() string {
+ if t == nil {
+ return "" // This is what would typically happen with a typed nil
+ }
+ return t.errorMsg
+}
+
+type testNonErrorResponse struct {
+ data string
+}
+
+type testValueErrorResponse struct {
+ errorMsg string
+}
+
+func (t testValueErrorResponse) GetError() string {
+ return t.errorMsg
+}
diff --git a/plugins/capabilities.go b/plugins/capabilities.go
deleted file mode 100644
index b86475028..000000000
--- a/plugins/capabilities.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package plugins
-
-import (
- "regexp"
- "slices"
-
- "github.com/navidrome/navidrome/core/agents"
-)
-
-// Capability represents a plugin capability type.
-// Capabilities are detected by checking which functions a plugin exports.
-type Capability string
-
-// capabilityFunctions maps each capability to its required/optional functions.
-// A plugin has a capability if it exports at least one of these functions.
-var capabilityFunctions = map[Capability][]string{}
-
-// registerCapability registers a capability with its associated functions.
-func registerCapability(cap Capability, functions ...string) {
- capabilityFunctions[cap] = functions
-}
-
-// functionExistsChecker is an interface for checking if a function exists in a plugin.
-// This allows for testing without a real plugin instance.
-type functionExistsChecker interface {
- FunctionExists(name string) bool
-}
-
-// detectCapabilities detects which capabilities a plugin has by checking
-// which functions it exports.
-func detectCapabilities(plugin functionExistsChecker) []Capability {
- var capabilities []Capability
-
- for cap, functions := range capabilityFunctions {
- if slices.ContainsFunc(functions, plugin.FunctionExists) {
- capabilities = append(capabilities, cap) // Found at least one function, plugin has this capability
- }
- }
-
- return capabilities
-}
-
-// hasCapability checks if the given capabilities slice contains a specific 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
deleted file mode 100644
index 2ad4a82da..000000000
--- a/plugins/capabilities/README.md
+++ /dev/null
@@ -1,87 +0,0 @@
-# Navidrome Plugin Capabilities
-
-This directory contains the Go interface definitions for Navidrome plugin capabilities. These interfaces are the **source of truth** for plugin development and are used to generate:
-
-1. **Go PDK packages** (`pdk/go/*/`) - Type-safe wrappers for Go plugin developers
-2. **Rust PDK crates** (`pdk/rust/*/`) - Type-safe wrappers for Rust plugin developers
-3. **XTP YAML schemas** (`*.yaml`) - Schema files for other [Extism plugin languages](https://extism.org/docs/concepts/pdk/) (TypeScript, Python, C#, Zig, C++, ...)
-
-## For Go Plugin Developers
-
-Go developers should use the generated PDK packages in `plugins/pdk/go/`. See the example Go plugins in `plugins/examples/` for usage patterns.
-
-## For Rust Plugin Developers
-
-Rust developers should use the generated PDK crate in `plugins/pdk/rust/nd-pdk`. See the example Rust plugins in `plugins/examples` for usage patterns.
-
-## For Non-Go Plugin Developers
-
-If you're developing plugins in other languages (TypeScript, Rust, Python, C#, Zig, C++), you can use the XTP CLI to generate type-safe bindings from the YAML schema files in this directory.
-
-### Prerequisites
-
-Install the XTP CLI:
-
-```bash
-# macOS
-brew install dylibso/tap/xtp
-
-# Other platforms - see https://docs.xtp.dylibso.com/docs/cli
-curl https://static.dylibso.com/cli/install.sh | bash
-```
-
-### Generating Plugin Scaffolding
-
-Use the XTP CLI to generate plugin boilerplate from any capability schema:
-
-```bash
-# TypeScript
-xtp plugin init --schema-file plugins/capabilities/metadata_agent.yaml \
- --template typescript --path my-plugin
-
-# Rust
-xtp plugin init --schema-file plugins/capabilities/scrobbler.yaml \
- --template rust --path my-plugin
-
-# Python
-xtp plugin init --schema-file plugins/capabilities/lifecycle.yaml \
- --template python --path my-plugin
-
-# C#
-xtp plugin init --schema-file plugins/capabilities/scheduler_callback.yaml \
- --template csharp --path my-plugin
-
-# Go (alternative to using the PDK packages)
-xtp plugin init --schema-file plugins/capabilities/websocket_callback.yaml \
- --template go --path my-plugin
-```
-
-### Available Capabilities
-
-| Capability | Schema File | Description |
-|--------------------|---------------------------|-------------------------------------------------------------|
-| Metadata Agent | `metadata_agent.yaml` | Fetch artist biographies, album images, and similar artists |
-| Scrobbler | `scrobbler.yaml` | Report listening activity to external services |
-| Lifecycle | `lifecycle.yaml` | Plugin initialization callbacks |
-| Scheduler Callback | `scheduler_callback.yaml` | Scheduled task execution |
-| WebSocket Callback | `websocket_callback.yaml` | Real-time WebSocket message handling |
-
-### Building Your Plugin
-
-After generating the scaffolding, implement the required functions and build your plugin as a WebAssembly module. The exact build process depends on your chosen language - see the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for language-specific guides.
-
-## XTP Schema Generation
-
-The YAML schemas in this package are automatically generated from the capability Go interfaces using `ndpgen`.
-To regenerate the schemas after modifying the interfaces, run:
-
-```bash
-cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types
-```
-
-## Resources
-
-- [XTP Documentation](https://docs.xtp.dylibso.com/)
-- [XTP Bindgen Repository](https://github.com/dylibso/xtp-bindgen)
-- [Extism Plugin Development Kit](https://extism.org/docs/concepts/pdk)
-- [XTP Schema Definition](https://raw.githubusercontent.com/dylibso/xtp-bindgen/5090518dd86ba5e734dc225a33066ecc0ed2e12d/plugin/schema.json)
diff --git a/plugins/capabilities/doc.go b/plugins/capabilities/doc.go
deleted file mode 100644
index fa9b7eb5d..000000000
--- a/plugins/capabilities/doc.go
+++ /dev/null
@@ -1,56 +0,0 @@
-// Package capabilities defines Go interfaces for Navidrome plugin capabilities.
-//
-// These interfaces serve as the source of truth for capability definitions.
-// The ndpgen tool generates:
-// - Go export wrappers in plugins/pdk/go// for Go plugins
-// - XTP YAML schemas for non-Go plugins (Rust, TypeScript, etc.)
-//
-// Each capability is defined as an annotated interface:
-//
-// //nd:capability name=metadata
-// type MetadataAgent interface {
-// //nd:export name=nd_get_artist_biography
-// GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error)
-// }
-//
-// Annotation Reference:
-//
-// //nd:capability name= [required=true]
-// - Marks an interface as a capability
-// - name: Generated package name (e.g., name=metadata → pdk/go/metadata/)
-// - required: If true, all methods must be implemented (default: false)
-//
-// //nd:export name=
-// - Marks a method as an exported WASM function
-// - name: The export name (e.g., nd_get_artist_biography)
-//
-// Generated Code Structure:
-//
-// For a capability like MetadataAgent with required=false:
-//
-// package metadata
-//
-// // Agent is the marker interface
-// type Agent interface{}
-//
-// // Optional provider interfaces
-// type ArtistBiographyProvider interface {
-// GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error)
-// }
-//
-// // Registration function
-// func Register(impl Agent) { ... }
-//
-// For a capability with required=true:
-//
-// package scrobbler
-//
-// // Scrobbler requires all methods
-// type Scrobbler interface {
-// IsAuthorized(IsAuthorizedRequest) (bool, error)
-// NowPlaying(NowPlayingRequest) error
-// Scrobble(ScrobbleRequest) error
-// }
-//
-// func Register(impl Scrobbler) { ... }
-package capabilities
diff --git a/plugins/capabilities/lifecycle.go b/plugins/capabilities/lifecycle.go
deleted file mode 100644
index b5f19ec5b..000000000
--- a/plugins/capabilities/lifecycle.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package capabilities
-
-// Lifecycle provides plugin lifecycle hooks.
-// This capability allows plugins to perform initialization when loaded,
-// such as establishing connections, starting background processes, or
-// validating configuration.
-//
-// The OnInit function is called once when the plugin is loaded, and is NOT
-// called when the plugin is hot-reloaded. Plugins should not assume this
-// function will be called on every startup.
-//
-//nd:capability name=lifecycle
-type Lifecycle interface {
- // OnInit is called after a plugin is fully loaded with all services registered.
- // Plugins can use this function to perform one-time initialization tasks.
- // Errors are logged but will not prevent the plugin from being loaded.
- //nd:export name=nd_on_init
- OnInit() error
-}
diff --git a/plugins/capabilities/lifecycle.yaml b/plugins/capabilities/lifecycle.yaml
deleted file mode 100644
index 7c6af62b8..000000000
--- a/plugins/capabilities/lifecycle.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-version: v1-draft
-exports:
- nd_on_init:
- description: |-
- OnInit is called after a plugin is fully loaded with all services registered.
- Plugins can use this function to perform one-time initialization tasks.
- Errors are logged but will not prevent the plugin from being loaded.
diff --git a/plugins/capabilities/lyrics.go b/plugins/capabilities/lyrics.go
deleted file mode 100644
index 6f6d19177..000000000
--- a/plugins/capabilities/lyrics.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package capabilities
-
-// Lyrics provides lyrics for a given track from external sources.
-//
-//nd:capability name=lyrics required=true
-type Lyrics interface {
- //nd:export name=nd_lyrics_get_lyrics
- GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
-}
-
-// GetLyricsRequest contains the track information for lyrics lookup.
-type GetLyricsRequest struct {
- Track TrackInfo `json:"track"`
-}
-
-// GetLyricsResponse contains the lyrics returned by the plugin.
-type GetLyricsResponse struct {
- Lyrics []LyricsText `json:"lyrics"`
-}
-
-// LyricsText represents a single set of lyrics in raw text format.
-// Text can be plain text or LRC format — Navidrome will parse it.
-type LyricsText struct {
- Lang string `json:"lang,omitempty"`
- Text string `json:"text"`
-}
diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml
deleted file mode 100644
index 7336124fc..000000000
--- a/plugins/capabilities/lyrics.yaml
+++ /dev/null
@@ -1,140 +0,0 @@
-version: v1-draft
-exports:
- nd_lyrics_get_lyrics:
- input:
- $ref: '#/components/schemas/GetLyricsRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/GetLyricsResponse'
- contentType: application/json
-components:
- schemas:
- GetLyricsRequest:
- description: GetLyricsRequest contains the track information for lyrics lookup.
- properties:
- track:
- $ref: '#/components/schemas/TrackInfo'
- required:
- - track
- GetLyricsResponse:
- description: GetLyricsResponse contains the lyrics returned by the plugin.
- properties:
- lyrics:
- type: array
- items:
- $ref: '#/components/schemas/LyricsText'
- required:
- - lyrics
- LyricsText:
- description: |-
- LyricsText represents a single set of lyrics in raw text format.
- Text can be plain text or LRC format — Navidrome will parse it.
- properties:
- lang:
- type: string
- text:
- type: string
- required:
- - text
- TrackInfo:
- description: TrackInfo contains track metadata.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome track ID.
- title:
- type: string
- description: Title is the track title.
- album:
- type: string
- description: Album is the album name.
- artist:
- type: string
- description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
- albumArtist:
- type: string
- description: AlbumArtist is the formatted album artist name for display.
- artists:
- type: array
- description: Artists is the list of track artists.
- items:
- $ref: '#/components/schemas/ArtistRef'
- albumArtists:
- type: array
- description: AlbumArtists is the list of album artists.
- items:
- $ref: '#/components/schemas/ArtistRef'
- duration:
- type: number
- format: float
- description: Duration is the track duration in seconds.
- trackNumber:
- type: integer
- format: int32
- description: TrackNumber is the track number on the album.
- discNumber:
- type: integer
- format: int32
- description: DiscNumber is the disc number.
- mbzRecordingId:
- type: string
- description: MBZRecordingID is the MusicBrainz recording ID.
- mbzAlbumId:
- type: string
- description: MBZAlbumID is the MusicBrainz album/release ID.
- mbzReleaseGroupId:
- type: string
- description: MBZReleaseGroupID is the MusicBrainz release group ID.
- mbzReleaseTrackId:
- type: string
- description: MBZReleaseTrackID is the MusicBrainz release track ID.
- libraryId:
- type: integer
- format: int32
- description: |-
- LibraryID is the ID of the library the track belongs to.
- Only included if the plugin has library permission with filesystem access for the track's library.
- path:
- type: string
- description: |-
- Path is the full path to the track file, relative to the library root.
- Only included if the plugin has library permission with filesystem access for the track's library.
- required:
- - id
- - title
- - album
- - artist
- - albumArtist
- - artists
- - albumArtists
- - 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
deleted file mode 100644
index 8fec7f5a8..000000000
--- a/plugins/capabilities/metadata_agent.go
+++ /dev/null
@@ -1,238 +0,0 @@
-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.
-//
-// 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.
- //nd:export name=nd_get_artist_mbid
- GetArtistMBID(ArtistMBIDRequest) (*ArtistMBIDResponse, error)
-
- // GetArtistURL retrieves the external URL for an artist.
- //nd:export name=nd_get_artist_url
- GetArtistURL(ArtistRequest) (*ArtistURLResponse, error)
-
- // GetArtistBiography retrieves the biography for an artist.
- //nd:export name=nd_get_artist_biography
- GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error)
-
- // GetSimilarArtists retrieves similar artists for a given artist.
- //nd:export name=nd_get_similar_artists
- GetSimilarArtists(SimilarArtistsRequest) (*SimilarArtistsResponse, error)
-
- // GetArtistImages retrieves images for an artist.
- //nd:export name=nd_get_artist_images
- GetArtistImages(ArtistRequest) (*ArtistImagesResponse, error)
-
- // GetArtistTopSongs retrieves top songs for an artist.
- //nd:export name=nd_get_artist_top_songs
- GetArtistTopSongs(TopSongsRequest) (*TopSongsResponse, error)
-
- // GetAlbumInfo retrieves album information.
- //nd:export name=nd_get_album_info
- GetAlbumInfo(AlbumRequest) (*AlbumInfoResponse, error)
-
- // GetAlbumImages retrieves images for an album.
- //nd:export name=nd_get_album_images
- GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
-
- // GetSimilarSongsByTrack retrieves songs similar to a specific track.
- //nd:export name=nd_get_similar_songs_by_track
- GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
-
- // GetSimilarSongsByAlbum retrieves songs similar to tracks on an album.
- //nd:export name=nd_get_similar_songs_by_album
- GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
-
- // GetSimilarSongsByArtist retrieves songs similar to an artist's catalog.
- //nd:export name=nd_get_similar_songs_by_artist
- GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
-}
-
-// ArtistMBIDRequest is the request for GetArtistMBID.
-type ArtistMBIDRequest struct {
- // ID is the internal Navidrome artist ID.
- ID string `json:"id"`
- // Name is the artist name.
- Name string `json:"name"`
-}
-
-// ArtistMBIDResponse is the response for GetArtistMBID.
-type ArtistMBIDResponse struct {
- // MBID is the MusicBrainz ID for the artist.
- MBID string `json:"mbid"`
-}
-
-// ArtistRequest is the common request for artist-related functions.
-type ArtistRequest struct {
- // ID is the internal Navidrome artist ID.
- ID string `json:"id"`
- // Name is the artist name.
- Name string `json:"name"`
- // MBID is the MusicBrainz ID for the artist (if known).
- MBID string `json:"mbid,omitempty"`
-}
-
-// ArtistURLResponse is the response for GetArtistURL.
-type ArtistURLResponse struct {
- // URL is the external URL for the artist.
- URL string `json:"url"`
-}
-
-// ArtistBiographyResponse is the response for GetArtistBiography.
-type ArtistBiographyResponse struct {
- // Biography is the artist biography text.
- Biography string `json:"biography"`
-}
-
-// SimilarArtistsRequest is the request for GetSimilarArtists.
-type SimilarArtistsRequest struct {
- // ID is the internal Navidrome artist ID.
- ID string `json:"id"`
- // Name is the artist name.
- Name string `json:"name"`
- // MBID is the MusicBrainz ID for the artist (if known).
- MBID string `json:"mbid,omitempty"`
- // Limit is the maximum number of similar artists to return.
- 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"`
-}
-
-// ImageInfo represents an image with URL and size.
-type ImageInfo struct {
- // URL is the URL of the image.
- URL string `json:"url"`
- // Size is the size of the image in pixels (width or height).
- Size int32 `json:"size"`
-}
-
-// ArtistImagesResponse is the response for GetArtistImages.
-type ArtistImagesResponse struct {
- // Images is the list of artist images.
- Images []ImageInfo `json:"images"`
-}
-
-// TopSongsRequest is the request for GetArtistTopSongs.
-type TopSongsRequest struct {
- // ID is the internal Navidrome artist ID.
- ID string `json:"id"`
- // Name is the artist name.
- Name string `json:"name"`
- // MBID is the MusicBrainz ID for the artist (if known).
- MBID string `json:"mbid,omitempty"`
- // Count is the maximum number of top songs to return.
- Count int32 `json:"count"`
-}
-
-// Deprecated: use types.SongRef.
-type SongRef = types.SongRef
-
-// TopSongsResponse is the response for GetArtistTopSongs.
-type TopSongsResponse struct {
- // Songs is the list of top songs.
- Songs []types.SongRef `json:"songs"`
-}
-
-// AlbumRequest is the common request for album-related functions.
-type AlbumRequest struct {
- // Name is the album name.
- Name string `json:"name"`
- // Artist is the album artist name.
- Artist string `json:"artist"`
- // MBID is the MusicBrainz ID for the album (if known).
- MBID string `json:"mbid,omitempty"`
-}
-
-// AlbumInfoResponse is the response for GetAlbumInfo.
-type AlbumInfoResponse struct {
- // Name is the album name.
- Name string `json:"name"`
- // MBID is the MusicBrainz ID for the album.
- MBID string `json:"mbid"`
- // Description is the album description/notes.
- Description string `json:"description"`
- // URL is the external URL for the album.
- URL string `json:"url"`
-}
-
-// AlbumImagesResponse is the response for GetAlbumImages.
-type AlbumImagesResponse struct {
- // Images is the list of album images.
- Images []ImageInfo `json:"images"`
-}
-
-// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
-type SimilarSongsByTrackRequest struct {
- // ID is the internal Navidrome mediafile ID.
- ID string `json:"id"`
- // Name is the track title.
- Name string `json:"name"`
- // Artist is the artist name.
- Artist string `json:"artist"`
- // MBID is the MusicBrainz recording ID (if known).
- MBID string `json:"mbid,omitempty"`
- // Count is the maximum number of similar songs to return.
- Count int32 `json:"count"`
-}
-
-// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
-type SimilarSongsByAlbumRequest struct {
- // ID is the internal Navidrome album ID.
- ID string `json:"id"`
- // Name is the album name.
- Name string `json:"name"`
- // Artist is the album artist name.
- Artist string `json:"artist"`
- // MBID is the MusicBrainz release ID (if known).
- MBID string `json:"mbid,omitempty"`
- // Count is the maximum number of similar songs to return.
- Count int32 `json:"count"`
-}
-
-// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
-type SimilarSongsByArtistRequest struct {
- // ID is the internal Navidrome artist ID.
- ID string `json:"id"`
- // Name is the artist name.
- Name string `json:"name"`
- // MBID is the MusicBrainz artist ID (if known).
- MBID string `json:"mbid,omitempty"`
- // Count is the maximum number of similar songs to return.
- Count int32 `json:"count"`
-}
-
-// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
-type SimilarSongsResponse struct {
- // Songs is the list of similar songs.
- Songs []types.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
deleted file mode 100644
index 6e528c7e7..000000000
--- a/plugins/capabilities/metadata_agent.yaml
+++ /dev/null
@@ -1,437 +0,0 @@
-version: v1-draft
-exports:
- nd_get_artist_mbid:
- description: GetArtistMBID retrieves the MusicBrainz ID for an artist.
- input:
- $ref: '#/components/schemas/ArtistMBIDRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/ArtistMBIDResponse'
- contentType: application/json
- nd_get_artist_url:
- description: GetArtistURL retrieves the external URL for an artist.
- input:
- $ref: '#/components/schemas/ArtistRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/ArtistURLResponse'
- contentType: application/json
- nd_get_artist_biography:
- description: GetArtistBiography retrieves the biography for an artist.
- input:
- $ref: '#/components/schemas/ArtistRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/ArtistBiographyResponse'
- contentType: application/json
- nd_get_similar_artists:
- description: GetSimilarArtists retrieves similar artists for a given artist.
- input:
- $ref: '#/components/schemas/SimilarArtistsRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/SimilarArtistsResponse'
- contentType: application/json
- nd_get_artist_images:
- description: GetArtistImages retrieves images for an artist.
- input:
- $ref: '#/components/schemas/ArtistRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/ArtistImagesResponse'
- contentType: application/json
- nd_get_artist_top_songs:
- description: GetArtistTopSongs retrieves top songs for an artist.
- input:
- $ref: '#/components/schemas/TopSongsRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/TopSongsResponse'
- contentType: application/json
- nd_get_album_info:
- description: GetAlbumInfo retrieves album information.
- input:
- $ref: '#/components/schemas/AlbumRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/AlbumInfoResponse'
- contentType: application/json
- nd_get_album_images:
- description: GetAlbumImages retrieves images for an album.
- input:
- $ref: '#/components/schemas/AlbumRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/AlbumImagesResponse'
- contentType: application/json
- nd_get_similar_songs_by_track:
- description: GetSimilarSongsByTrack retrieves songs similar to a specific track.
- input:
- $ref: '#/components/schemas/SimilarSongsByTrackRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/SimilarSongsResponse'
- contentType: application/json
- nd_get_similar_songs_by_album:
- description: GetSimilarSongsByAlbum retrieves songs similar to tracks on an album.
- input:
- $ref: '#/components/schemas/SimilarSongsByAlbumRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/SimilarSongsResponse'
- contentType: application/json
- nd_get_similar_songs_by_artist:
- description: GetSimilarSongsByArtist retrieves songs similar to an artist's catalog.
- input:
- $ref: '#/components/schemas/SimilarSongsByArtistRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/SimilarSongsResponse'
- contentType: application/json
-components:
- schemas:
- AlbumImagesResponse:
- description: AlbumImagesResponse is the response for GetAlbumImages.
- properties:
- images:
- type: array
- description: Images is the list of album images.
- items:
- $ref: '#/components/schemas/ImageInfo'
- required:
- - images
- AlbumInfoResponse:
- description: AlbumInfoResponse is the response for GetAlbumInfo.
- properties:
- name:
- type: string
- description: Name is the album name.
- mbid:
- type: string
- description: MBID is the MusicBrainz ID for the album.
- description:
- type: string
- description: Description is the album description/notes.
- url:
- type: string
- description: URL is the external URL for the album.
- required:
- - name
- - mbid
- - description
- - url
- AlbumRequest:
- description: AlbumRequest is the common request for album-related functions.
- properties:
- name:
- type: string
- description: Name is the album name.
- artist:
- type: string
- description: Artist is the album artist name.
- mbid:
- type: string
- description: MBID is the MusicBrainz ID for the album (if known).
- required:
- - name
- - artist
- ArtistBiographyResponse:
- description: ArtistBiographyResponse is the response for GetArtistBiography.
- properties:
- biography:
- type: string
- description: Biography is the artist biography text.
- required:
- - biography
- ArtistImagesResponse:
- description: ArtistImagesResponse is the response for GetArtistImages.
- properties:
- images:
- type: array
- description: Images is the list of artist images.
- items:
- $ref: '#/components/schemas/ImageInfo'
- required:
- - images
- ArtistMBIDRequest:
- description: ArtistMBIDRequest is the request for GetArtistMBID.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome artist ID.
- name:
- type: string
- description: Name is the artist name.
- required:
- - id
- - name
- ArtistMBIDResponse:
- description: ArtistMBIDResponse is the response for GetArtistMBID.
- properties:
- mbid:
- type: string
- description: MBID is the MusicBrainz ID for the artist.
- required:
- - mbid
- ArtistRequest:
- description: ArtistRequest is the common request for artist-related functions.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome artist ID.
- name:
- type: string
- description: Name is the artist name.
- mbid:
- type: string
- description: MBID is the MusicBrainz ID for the artist (if known).
- required:
- - id
- - name
- ArtistURLResponse:
- description: ArtistURLResponse is the response for GetArtistURL.
- properties:
- url:
- type: string
- description: URL is the external URL for the artist.
- required:
- - url
- ImageInfo:
- description: ImageInfo represents an image with URL and size.
- properties:
- url:
- type: string
- description: URL is the URL of the image.
- size:
- type: integer
- format: int32
- description: Size is the size of the image in pixels (width or height).
- required:
- - url
- - size
- SimilarArtistsRequest:
- description: SimilarArtistsRequest is the request for GetSimilarArtists.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome artist ID.
- name:
- type: string
- description: Name is the artist name.
- mbid:
- type: string
- description: MBID is the MusicBrainz ID for the artist (if known).
- limit:
- type: integer
- format: int32
- description: Limit is the maximum number of similar artists to return.
- required:
- - id
- - name
- - limit
- SimilarArtistsResponse:
- description: SimilarArtistsResponse is the response for GetSimilarArtists.
- properties:
- artists:
- type: array
- description: Artists is the list of similar artists.
- items:
- $ref: '#/components/schemas/ArtistRef'
- required:
- - artists
- SimilarSongsByAlbumRequest:
- description: SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome album ID.
- name:
- type: string
- description: Name is the album name.
- artist:
- type: string
- description: Artist is the album artist name.
- mbid:
- type: string
- description: MBID is the MusicBrainz release ID (if known).
- count:
- type: integer
- format: int32
- description: Count is the maximum number of similar songs to return.
- required:
- - id
- - name
- - artist
- - count
- SimilarSongsByArtistRequest:
- description: SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome artist ID.
- name:
- type: string
- description: Name is the artist name.
- mbid:
- type: string
- description: MBID is the MusicBrainz artist ID (if known).
- count:
- type: integer
- format: int32
- description: Count is the maximum number of similar songs to return.
- required:
- - id
- - name
- - count
- SimilarSongsByTrackRequest:
- description: SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome mediafile ID.
- name:
- type: string
- description: Name is the track title.
- artist:
- type: string
- description: Artist is the artist name.
- mbid:
- type: string
- description: MBID is the MusicBrainz recording ID (if known).
- count:
- type: integer
- format: int32
- description: Count is the maximum number of similar songs to return.
- required:
- - id
- - name
- - artist
- - count
- SimilarSongsResponse:
- description: SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
- properties:
- songs:
- type: array
- description: Songs is the list of similar songs.
- items:
- $ref: '#/components/schemas/SongRef'
- required:
- - songs
- TopSongsRequest:
- description: TopSongsRequest is the request for GetArtistTopSongs.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome artist ID.
- name:
- type: string
- description: Name is the artist name.
- mbid:
- type: string
- description: MBID is the MusicBrainz ID for the artist (if known).
- count:
- type: integer
- format: int32
- description: Count is the maximum number of top songs to return.
- required:
- - id
- - name
- - count
- TopSongsResponse:
- description: TopSongsResponse is the response for GetArtistTopSongs.
- properties:
- songs:
- type: array
- description: Songs is the list of top songs.
- items:
- $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/scheduler_callback.go b/plugins/capabilities/scheduler_callback.go
deleted file mode 100644
index 93f66f10d..000000000
--- a/plugins/capabilities/scheduler_callback.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package capabilities
-
-// SchedulerCallback provides scheduled task handling.
-// This capability allows plugins to receive callbacks when their scheduled tasks execute.
-// Plugins that use the scheduler host service must implement this capability
-// to handle task execution.
-//
-//nd:capability name=scheduler
-type SchedulerCallback interface {
- // OnCallback is called when a scheduled task fires.
- // Errors are logged but do not affect the scheduling system.
- //nd:export name=nd_scheduler_callback
- OnCallback(SchedulerCallbackRequest) error
-}
-
-// SchedulerCallbackRequest is the request provided when a scheduled task fires.
-type SchedulerCallbackRequest struct {
- // ScheduleID is the unique identifier for this scheduled task.
- // This is either the ID provided when scheduling, or an auto-generated UUID if none was specified.
- ScheduleID string `json:"scheduleId"`
- // Payload is the payload data that was provided when the task was scheduled.
- // Can be used to pass context or parameters to the callback handler.
- Payload string `json:"payload"`
- // IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring),
- // false if it's a one-time schedule (created via ScheduleOneTime).
- IsRecurring bool `json:"isRecurring"`
-}
diff --git a/plugins/capabilities/scheduler_callback.yaml b/plugins/capabilities/scheduler_callback.yaml
deleted file mode 100644
index 9a081cd08..000000000
--- a/plugins/capabilities/scheduler_callback.yaml
+++ /dev/null
@@ -1,33 +0,0 @@
-version: v1-draft
-exports:
- nd_scheduler_callback:
- description: |-
- OnCallback is called when a scheduled task fires.
- Errors are logged but do not affect the scheduling system.
- input:
- $ref: '#/components/schemas/SchedulerCallbackRequest'
- contentType: application/json
-components:
- schemas:
- SchedulerCallbackRequest:
- description: SchedulerCallbackRequest is the request provided when a scheduled task fires.
- properties:
- scheduleId:
- type: string
- description: |-
- ScheduleID is the unique identifier for this scheduled task.
- This is either the ID provided when scheduling, or an auto-generated UUID if none was specified.
- payload:
- type: string
- description: |-
- Payload is the payload data that was provided when the task was scheduled.
- Can be used to pass context or parameters to the callback handler.
- isRecurring:
- type: boolean
- description: |-
- IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring),
- false if it's a one-time schedule (created via ScheduleOneTime).
- required:
- - scheduleId
- - payload
- - isRecurring
diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go
deleted file mode 100644
index c1c05839a..000000000
--- a/plugins/capabilities/scrobbler.go
+++ /dev/null
@@ -1,128 +0,0 @@
-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.
-//
-// All methods are required - plugins implementing this capability must provide
-// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
-//
-//nd:capability name=scrobbler required=true
-type Scrobbler interface {
- // IsAuthorized checks if a user is authorized to scrobble to this service.
- //nd:export name=nd_scrobbler_is_authorized
- IsAuthorized(IsAuthorizedRequest) (bool, error)
-
- // NowPlaying sends a now playing notification to the scrobbling service.
- //nd:export name=nd_scrobbler_now_playing
- NowPlaying(NowPlayingRequest) error
-
- // Scrobble submits a completed scrobble to the scrobbling service.
- //nd:export name=nd_scrobbler_scrobble
- Scrobble(ScrobbleRequest) error
-
- // PlaybackReport sends a playback state report to the scrobbling service.
- //nd:export name=nd_scrobbler_playback_report
- PlaybackReport(PlaybackReportRequest) error
-}
-
-// IsAuthorizedRequest is the request for authorization check.
-type IsAuthorizedRequest struct {
- // Username is the username of the user.
- Username string `json:"username"`
-}
-
-// TrackInfo contains track metadata.
-type TrackInfo struct {
- // ID is the internal Navidrome track ID.
- ID string `json:"id"`
- // Title is the track title.
- Title string `json:"title"`
- // Album is the album name.
- Album string `json:"album"`
- // Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
- Artist string `json:"artist"`
- // 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"`
- // AlbumArtists is the list of album artists.
- AlbumArtists []types.ArtistRef `json:"albumArtists"`
- // Duration is the track duration in seconds.
- Duration float32 `json:"duration"`
- // TrackNumber is the track number on the album.
- TrackNumber int32 `json:"trackNumber"`
- // DiscNumber is the disc number.
- DiscNumber int32 `json:"discNumber"`
- // MBZRecordingID is the MusicBrainz recording ID.
- MBZRecordingID string `json:"mbzRecordingId,omitempty"`
- // MBZAlbumID is the MusicBrainz album/release ID.
- MBZAlbumID string `json:"mbzAlbumId,omitempty"`
- // MBZReleaseGroupID is the MusicBrainz release group ID.
- MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
- // MBZReleaseTrackID is the MusicBrainz release track ID.
- MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
- // LibraryID is the ID of the library the track belongs to.
- // Only included if the plugin has library permission with filesystem access for the track's library.
- LibraryID int32 `json:"libraryId,omitempty"`
- // Path is the full path to the track file, relative to the library root.
- // Only included if the plugin has library permission with filesystem access for the track's library.
- Path string `json:"path,omitempty"`
-}
-
-// NowPlayingRequest is the request for now playing notification.
-type NowPlayingRequest struct {
- // Username is the username of the user.
- Username string `json:"username"`
- // Track is the track currently playing.
- Track TrackInfo `json:"track"`
- // Position is the current playback position in seconds.
- Position int32 `json:"position"`
-}
-
-// ScrobbleRequest is the request for submitting a scrobble.
-type ScrobbleRequest struct {
- // Username is the username of the user.
- Username string `json:"username"`
- // Track is the track that was played.
- Track TrackInfo `json:"track"`
- // Timestamp is the Unix timestamp when the track started playing.
- Timestamp int64 `json:"timestamp"`
-}
-
-// PlaybackReportRequest is the request for playback report notifications.
-type PlaybackReportRequest struct {
- // Username is the username of the user.
- Username string `json:"username"`
- // Track is the track being played.
- Track TrackInfo `json:"track"`
- // State is the current playback state (starting/playing/paused/stopped/expired).
- State string `json:"state"`
- // PositionMs is the current playback position in milliseconds.
- PositionMs int64 `json:"positionMs"`
- // PlaybackRate is the playback speed (1.0 = normal).
- PlaybackRate float64 `json:"playbackRate"`
- // PlayerId is the unique client identifier.
- PlayerId string `json:"playerId"`
- // PlayerName is the human-readable player name.
- PlayerName string `json:"playerName"`
- // Timestamp is the Unix timestamp when this report was generated.
- Timestamp int64 `json:"timestamp"`
-}
-
-// ScrobblerError represents an error type for scrobbling operations.
-type ScrobblerError string
-
-const (
- // ScrobblerErrorNotAuthorized indicates the user is not authorized.
- ScrobblerErrorNotAuthorized ScrobblerError = "scrobbler(not_authorized)"
- // ScrobblerErrorRetryLater indicates the operation should be retried later.
- ScrobblerErrorRetryLater ScrobblerError = "scrobbler(retry_later)"
- // ScrobblerErrorUnrecoverable indicates an unrecoverable error.
- ScrobblerErrorUnrecoverable ScrobblerError = "scrobbler(unrecoverable)"
-)
-
-// Error implements the error interface for ScrobblerError.
-func (e ScrobblerError) Error() string { return string(e) }
diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml
deleted file mode 100644
index 2b862b964..000000000
--- a/plugins/capabilities/scrobbler.yaml
+++ /dev/null
@@ -1,210 +0,0 @@
-version: v1-draft
-exports:
- nd_scrobbler_is_authorized:
- description: IsAuthorized checks if a user is authorized to scrobble to this service.
- input:
- $ref: '#/components/schemas/IsAuthorizedRequest'
- contentType: application/json
- output:
- type: boolean
- contentType: application/json
- nd_scrobbler_now_playing:
- description: NowPlaying sends a now playing notification to the scrobbling service.
- input:
- $ref: '#/components/schemas/NowPlayingRequest'
- contentType: application/json
- nd_scrobbler_scrobble:
- description: Scrobble submits a completed scrobble to the scrobbling service.
- input:
- $ref: '#/components/schemas/ScrobbleRequest'
- contentType: application/json
- nd_scrobbler_playback_report:
- description: PlaybackReport sends a playback state report to the scrobbling service.
- input:
- $ref: '#/components/schemas/PlaybackReportRequest'
- contentType: application/json
-components:
- schemas:
- IsAuthorizedRequest:
- description: IsAuthorizedRequest is the request for authorization check.
- properties:
- username:
- type: string
- description: Username is the username of the user.
- required:
- - username
- NowPlayingRequest:
- description: NowPlayingRequest is the request for now playing notification.
- properties:
- username:
- type: string
- description: Username is the username of the user.
- track:
- $ref: '#/components/schemas/TrackInfo'
- description: Track is the track currently playing.
- position:
- type: integer
- format: int32
- description: Position is the current playback position in seconds.
- required:
- - username
- - track
- - position
- PlaybackReportRequest:
- description: PlaybackReportRequest is the request for playback report notifications.
- properties:
- username:
- type: string
- description: Username is the username of the user.
- track:
- $ref: '#/components/schemas/TrackInfo'
- description: Track is the track being played.
- state:
- type: string
- description: State is the current playback state (starting/playing/paused/stopped/expired).
- positionMs:
- type: integer
- format: int64
- description: PositionMs is the current playback position in milliseconds.
- playbackRate:
- type: number
- format: float
- description: PlaybackRate is the playback speed (1.0 = normal).
- playerId:
- type: string
- description: PlayerId is the unique client identifier.
- playerName:
- type: string
- description: PlayerName is the human-readable player name.
- timestamp:
- type: integer
- format: int64
- description: Timestamp is the Unix timestamp when this report was generated.
- required:
- - username
- - track
- - state
- - positionMs
- - playbackRate
- - playerId
- - playerName
- - timestamp
- ScrobbleRequest:
- description: ScrobbleRequest is the request for submitting a scrobble.
- properties:
- username:
- type: string
- description: Username is the username of the user.
- track:
- $ref: '#/components/schemas/TrackInfo'
- description: Track is the track that was played.
- timestamp:
- type: integer
- format: int64
- description: Timestamp is the Unix timestamp when the track started playing.
- required:
- - username
- - track
- - timestamp
- TrackInfo:
- description: TrackInfo contains track metadata.
- properties:
- id:
- type: string
- description: ID is the internal Navidrome track ID.
- title:
- type: string
- description: Title is the track title.
- album:
- type: string
- description: Album is the album name.
- artist:
- type: string
- description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
- albumArtist:
- type: string
- description: AlbumArtist is the formatted album artist name for display.
- artists:
- type: array
- description: Artists is the list of track artists.
- items:
- $ref: '#/components/schemas/ArtistRef'
- albumArtists:
- type: array
- description: AlbumArtists is the list of album artists.
- items:
- $ref: '#/components/schemas/ArtistRef'
- duration:
- type: number
- format: float
- description: Duration is the track duration in seconds.
- trackNumber:
- type: integer
- format: int32
- description: TrackNumber is the track number on the album.
- discNumber:
- type: integer
- format: int32
- description: DiscNumber is the disc number.
- mbzRecordingId:
- type: string
- description: MBZRecordingID is the MusicBrainz recording ID.
- mbzAlbumId:
- type: string
- description: MBZAlbumID is the MusicBrainz album/release ID.
- mbzReleaseGroupId:
- type: string
- description: MBZReleaseGroupID is the MusicBrainz release group ID.
- mbzReleaseTrackId:
- type: string
- description: MBZReleaseTrackID is the MusicBrainz release track ID.
- libraryId:
- type: integer
- format: int32
- description: |-
- LibraryID is the ID of the library the track belongs to.
- Only included if the plugin has library permission with filesystem access for the track's library.
- path:
- type: string
- description: |-
- Path is the full path to the track file, relative to the library root.
- Only included if the plugin has library permission with filesystem access for the track's library.
- required:
- - id
- - title
- - album
- - artist
- - albumArtist
- - artists
- - albumArtists
- - 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
deleted file mode 100644
index a35d9d923..000000000
--- a/plugins/capabilities/sonic_similarity.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package capabilities
-
-import "github.com/navidrome/navidrome/plugins/types"
-
-// SonicSimilarity provides audio-similarity based track discovery.
-//
-//nd:capability name=sonicsimilarity required=true
-type SonicSimilarity interface {
- //nd:export name=nd_get_sonic_similar_tracks
- GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
-
- //nd:export name=nd_find_sonic_path
- FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error)
-}
-
-type GetSonicSimilarTracksRequest struct {
- Song types.SongRef `json:"song"`
- Count int32 `json:"count"`
-}
-
-type FindSonicPathRequest struct {
- StartSong types.SongRef `json:"startSong"`
- EndSong types.SongRef `json:"endSong"`
- Count int32 `json:"count"`
-}
-
-type SonicSimilarityResponse struct {
- Matches []SonicMatch `json:"matches"`
-}
-
-type SonicMatch struct {
- Song types.SongRef `json:"song"`
- Similarity float64 `json:"similarity"`
-}
diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml
deleted file mode 100644
index a336fbce8..000000000
--- a/plugins/capabilities/sonic_similarity.yaml
+++ /dev/null
@@ -1,147 +0,0 @@
-version: v1-draft
-exports:
- nd_get_sonic_similar_tracks:
- input:
- $ref: '#/components/schemas/GetSonicSimilarTracksRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/SonicSimilarityResponse'
- contentType: application/json
- nd_find_sonic_path:
- input:
- $ref: '#/components/schemas/FindSonicPathRequest'
- contentType: application/json
- output:
- $ref: '#/components/schemas/SonicSimilarityResponse'
- contentType: application/json
-components:
- schemas:
- FindSonicPathRequest:
- properties:
- startSong:
- $ref: '#/components/schemas/SongRef'
- endSong:
- $ref: '#/components/schemas/SongRef'
- count:
- type: integer
- format: int32
- required:
- - startSong
- - endSong
- - count
- GetSonicSimilarTracksRequest:
- properties:
- song:
- $ref: '#/components/schemas/SongRef'
- count:
- type: integer
- format: int32
- required:
- - song
- - count
- SonicMatch:
- properties:
- song:
- $ref: '#/components/schemas/SongRef'
- similarity:
- type: number
- format: float
- required:
- - song
- - similarity
- SonicSimilarityResponse:
- properties:
- matches:
- type: array
- items:
- $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/capabilities/taskworker.go b/plugins/capabilities/taskworker.go
deleted file mode 100644
index c53d50174..000000000
--- a/plugins/capabilities/taskworker.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package capabilities
-
-// TaskWorker provides task execution handling.
-// This capability allows plugins to receive callbacks when their queued tasks
-// are ready to execute. Plugins that use the taskqueue host service must
-// implement this capability.
-//
-//nd:capability name=taskworker
-type TaskWorker interface {
- // OnTaskExecute is called when a queued task is ready to run.
- // The returned string is a status/result message stored in the tasks table.
- // Return an error to trigger retry (if retries are configured).
- //nd:export name=nd_task_execute
- OnTaskExecute(TaskExecuteRequest) (string, error)
-}
-
-// TaskExecuteRequest is the request provided when a task is ready to execute.
-type TaskExecuteRequest struct {
- // QueueName is the name of the queue this task belongs to.
- QueueName string `json:"queueName"`
- // TaskID is the unique identifier for this task.
- TaskID string `json:"taskId"`
- // Payload is the opaque data provided when the task was enqueued.
- Payload []byte `json:"payload"`
- // Attempt is the current attempt number (1-based: first attempt = 1).
- Attempt int32 `json:"attempt"`
-}
diff --git a/plugins/capabilities/taskworker.yaml b/plugins/capabilities/taskworker.yaml
deleted file mode 100644
index 7aa7126e0..000000000
--- a/plugins/capabilities/taskworker.yaml
+++ /dev/null
@@ -1,37 +0,0 @@
-version: v1-draft
-exports:
- nd_task_execute:
- description: |-
- OnTaskExecute is called when a queued task is ready to run.
- The returned string is a status/result message stored in the tasks table.
- Return an error to trigger retry (if retries are configured).
- input:
- $ref: '#/components/schemas/TaskExecuteRequest'
- contentType: application/json
- output:
- type: string
- contentType: application/json
-components:
- schemas:
- TaskExecuteRequest:
- description: TaskExecuteRequest is the request provided when a task is ready to execute.
- properties:
- queueName:
- type: string
- description: QueueName is the name of the queue this task belongs to.
- taskId:
- type: string
- description: TaskID is the unique identifier for this task.
- payload:
- type: string
- format: byte
- description: Payload is the opaque data provided when the task was enqueued.
- attempt:
- type: integer
- format: int32
- description: 'Attempt is the current attempt number (1-based: first attempt = 1).'
- required:
- - queueName
- - taskId
- - payload
- - attempt
diff --git a/plugins/capabilities/websocket_callback.go b/plugins/capabilities/websocket_callback.go
deleted file mode 100644
index ddfc0fc95..000000000
--- a/plugins/capabilities/websocket_callback.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package capabilities
-
-// WebSocketCallback provides WebSocket message handling.
-// This capability allows plugins to receive callbacks for WebSocket events
-// such as text messages, binary messages, errors, and connection closures.
-// Plugins that use the WebSocket host service must implement this capability
-// to handle incoming events.
-//
-//nd:capability name=websocket
-type WebSocketCallback interface {
- // OnTextMessage is called when a text message is received on a WebSocket connection.
- //nd:export name=nd_websocket_on_text_message
- OnTextMessage(OnTextMessageRequest) error
-
- // OnBinaryMessage is called when a binary message is received on a WebSocket connection.
- //nd:export name=nd_websocket_on_binary_message
- OnBinaryMessage(OnBinaryMessageRequest) error
-
- // OnError is called when an error occurs on a WebSocket connection.
- //nd:export name=nd_websocket_on_error
- OnError(OnErrorRequest) error
-
- // OnClose is called when a WebSocket connection is closed.
- //nd:export name=nd_websocket_on_close
- OnClose(OnCloseRequest) error
-}
-
-// OnTextMessageRequest is the request provided when a text message is received.
-type OnTextMessageRequest struct {
- // ConnectionID is the unique identifier for the WebSocket connection that received the message.
- ConnectionID string `json:"connectionId"`
- // Message is the text message content received from the WebSocket.
- Message string `json:"message"`
-}
-
-// OnBinaryMessageRequest is the request provided when a binary message is received.
-type OnBinaryMessageRequest struct {
- // ConnectionID is the unique identifier for the WebSocket connection that received the message.
- ConnectionID string `json:"connectionId"`
- // Data is the binary data received from the WebSocket, encoded as base64.
- Data []byte `json:"data"`
-}
-
-// OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
-type OnErrorRequest struct {
- // ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
- ConnectionID string `json:"connectionId"`
- // Error is the error message describing what went wrong.
- Error string `json:"error"`
-}
-
-// OnCloseRequest is the request provided when a WebSocket connection is closed.
-type OnCloseRequest struct {
- // ConnectionID is the unique identifier for the WebSocket connection that was closed.
- ConnectionID string `json:"connectionId"`
- // Code is the WebSocket close status code (e.g., 1000 for normal closure,
- // 1001 for going away, 1006 for abnormal closure).
- Code int32 `json:"code"`
- // Reason is the human-readable reason for the connection closure, if provided.
- Reason string `json:"reason"`
-}
diff --git a/plugins/capabilities/websocket_callback.yaml b/plugins/capabilities/websocket_callback.yaml
deleted file mode 100644
index 6cd0cff9f..000000000
--- a/plugins/capabilities/websocket_callback.yaml
+++ /dev/null
@@ -1,80 +0,0 @@
-version: v1-draft
-exports:
- nd_websocket_on_text_message:
- description: OnTextMessage is called when a text message is received on a WebSocket connection.
- input:
- $ref: '#/components/schemas/OnTextMessageRequest'
- contentType: application/json
- nd_websocket_on_binary_message:
- description: OnBinaryMessage is called when a binary message is received on a WebSocket connection.
- input:
- $ref: '#/components/schemas/OnBinaryMessageRequest'
- contentType: application/json
- nd_websocket_on_error:
- description: OnError is called when an error occurs on a WebSocket connection.
- input:
- $ref: '#/components/schemas/OnErrorRequest'
- contentType: application/json
- nd_websocket_on_close:
- description: OnClose is called when a WebSocket connection is closed.
- input:
- $ref: '#/components/schemas/OnCloseRequest'
- contentType: application/json
-components:
- schemas:
- OnBinaryMessageRequest:
- description: OnBinaryMessageRequest is the request provided when a binary message is received.
- properties:
- connectionId:
- type: string
- description: ConnectionID is the unique identifier for the WebSocket connection that received the message.
- data:
- type: string
- format: byte
- description: Data is the binary data received from the WebSocket, encoded as base64.
- required:
- - connectionId
- - data
- OnCloseRequest:
- description: OnCloseRequest is the request provided when a WebSocket connection is closed.
- properties:
- connectionId:
- type: string
- description: ConnectionID is the unique identifier for the WebSocket connection that was closed.
- code:
- type: integer
- format: int32
- description: |-
- Code is the WebSocket close status code (e.g., 1000 for normal closure,
- 1001 for going away, 1006 for abnormal closure).
- reason:
- type: string
- description: Reason is the human-readable reason for the connection closure, if provided.
- required:
- - connectionId
- - code
- - reason
- OnErrorRequest:
- description: OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
- properties:
- connectionId:
- type: string
- description: ConnectionID is the unique identifier for the WebSocket connection where the error occurred.
- error:
- type: string
- description: Error is the error message describing what went wrong.
- required:
- - connectionId
- - error
- OnTextMessageRequest:
- description: OnTextMessageRequest is the request provided when a text message is received.
- properties:
- connectionId:
- type: string
- description: ConnectionID is the unique identifier for the WebSocket connection that received the message.
- message:
- type: string
- description: Message is the text message content received from the WebSocket.
- required:
- - connectionId
- - message
diff --git a/plugins/capabilities_test.go b/plugins/capabilities_test.go
deleted file mode 100644
index 35fc3910a..000000000
--- a/plugins/capabilities_test.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package plugins
-
-import (
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-// mockFunctionChecker implements functionExistsChecker for testing
-type mockFunctionChecker struct {
- functions map[string]bool
-}
-
-func (m *mockFunctionChecker) FunctionExists(name string) bool {
- return m.functions[name]
-}
-
-var _ = Describe("Capabilities", func() {
- Describe("detectCapabilities", func() {
- It("detects MetadataAgent capability when plugin exports artist biography function", func() {
- checker := &mockFunctionChecker{
- functions: map[string]bool{
- FuncGetArtistBiography: true,
- },
- }
-
- caps := detectCapabilities(checker)
- Expect(caps).To(ContainElement(CapabilityMetadataAgent))
- })
-
- It("detects MetadataAgent capability when plugin exports multiple functions", func() {
- checker := &mockFunctionChecker{
- functions: map[string]bool{
- FuncGetArtistMBID: true,
- FuncGetArtistURL: true,
- FuncGetAlbumInfo: true,
- FuncGetAlbumImages: true,
- },
- }
-
- caps := detectCapabilities(checker)
- Expect(caps).To(ContainElement(CapabilityMetadataAgent))
- Expect(caps).To(HaveLen(1)) // Should only have one MetadataAgent capability
- })
-
- It("returns empty slice when no capability functions are exported", func() {
- checker := &mockFunctionChecker{
- functions: map[string]bool{
- "some_other_function": true,
- },
- }
-
- caps := detectCapabilities(checker)
- Expect(caps).To(BeEmpty())
- })
-
- It("returns empty slice when plugin exports no functions", func() {
- checker := &mockFunctionChecker{
- functions: map[string]bool{},
- }
-
- caps := detectCapabilities(checker)
- Expect(caps).To(BeEmpty())
- })
- })
-
- Describe("hasCapability", func() {
- It("returns true when capability exists", func() {
- caps := []Capability{CapabilityMetadataAgent}
- Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeTrue())
- })
-
- It("returns false when capability does not exist", func() {
- var caps []Capability
- Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeFalse())
- })
-
- It("returns false when capabilities slice is nil", func() {
- Expect(hasCapability(nil, CapabilityMetadataAgent)).To(BeFalse())
- })
- })
-})
diff --git a/plugins/capability_lifecycle.go b/plugins/capability_lifecycle.go
deleted file mode 100644
index 499e3916d..000000000
--- a/plugins/capability_lifecycle.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package plugins
-
-import (
- "context"
-
- "github.com/navidrome/navidrome/log"
-)
-
-// CapabilityLifecycle indicates the plugin has lifecycle callback functions.
-// Detected when the plugin exports the nd_on_init function.
-const CapabilityLifecycle Capability = "Lifecycle"
-
-const FuncOnInit = "nd_on_init"
-
-func init() {
- registerCapability(
- CapabilityLifecycle,
- FuncOnInit,
- )
-}
-
-// callPluginInit calls the plugin's nd_on_init function if it has the Lifecycle capability.
-// This is called after the plugin is fully loaded with all services registered.
-func callPluginInit(ctx context.Context, instance *plugin) {
- if !hasCapability(instance.capabilities, CapabilityLifecycle) {
- return
- }
-
- log.Debug(ctx, "Calling plugin init function", "plugin", instance.name)
-
- err := callPluginFunctionNoInput(ctx, instance, FuncOnInit)
- if err != nil {
- log.Error(ctx, "Plugin init function failed", "plugin", instance.name, err)
- return
- }
-
- log.Debug(ctx, "Plugin init function completed", "plugin", instance.name)
-}
diff --git a/plugins/cmd/ndpgen/.gitignore b/plugins/cmd/ndpgen/.gitignore
deleted file mode 100644
index 315ccc05f..000000000
--- a/plugins/cmd/ndpgen/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-ndpgen
\ No newline at end of file
diff --git a/plugins/cmd/ndpgen/README.md b/plugins/cmd/ndpgen/README.md
deleted file mode 100644
index 7487db892..000000000
--- a/plugins/cmd/ndpgen/README.md
+++ /dev/null
@@ -1,193 +0,0 @@
-# ndpgen
-
-Navidrome Plugin Development Kit (PDK) code generator. It reads Go interface definitions with special annotations and generates client wrappers for WASM plugins.
-
-This tool is the unified code generator that handle both host function wrappers and capability wrappers.
-
-## Usage
-
-```bash
-ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-rust]
-```
-
-### Flags
-
-| Flag | Description | Default |
-|--------------|----------------------------------------------------------------|----------------------|
-| `-input` | Directory containing Go source files with annotated interfaces | Required |
-| `-output` | Directory where generated files will be written | Same as input |
-| `-package` | Package name for generated files | Inferred from output |
-| `-v` | Verbose output | `false` |
-| `-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`* |
-| `-rust` | Generate Rust client wrappers | `false` |
-
-\* `-go` is enabled by default when `-rust` is not specified. Use `-go -rust` to generate both languages.
-
-### Example
-
-```bash
-go run ./plugins/cmd/ndpgen \
- -input ./plugins/host \
- -output ./plugins/pdk
-```
-
-## Annotations
-
-### `//nd:hostservice`
-
-Marks an interface as a host service that will have wrappers generated.
-
-```go
-//nd:hostservice name= permission=
-type MyService interface { ... }
-```
-
-| Parameter | Description | Required |
-|--------------|-----------------------------------------------------------------|----------|
-| `name` | Service name used in generated type names and function prefixes | Yes |
-| `permission` | Permission required by plugins to use this service | Yes |
-
-### `//nd:hostfunc`
-
-Marks a method within a host service interface for export to plugins.
-
-```go
-//nd:hostfunc [name=]
-MethodName(ctx context.Context, ...) (result Type, err error)
-```
-
-| Parameter | Description | Required |
-|-----------|-------------------------------------------------------------------------|----------|
-| `name` | Custom export name (default: `_` in lowercase) | No |
-
-## Input Format
-
-Host service interfaces must follow these conventions:
-
-1. **First parameter must be `context.Context`** - Required for all methods
-2. **Last return value should be `error`** - For proper error handling
-3. **Annotations must be on consecutive lines** - No blank comment lines between doc and annotation
-
-### Example Interface
-
-```go
-package host
-
-import "context"
-
-// SubsonicAPIService provides access to Navidrome's Subsonic API.
-// This documentation becomes part of the generated code.
-//nd:hostservice name=SubsonicAPI permission=subsonicapi
-type SubsonicAPIService interface {
- // Call executes a Subsonic API request and returns the response.
- //nd:hostfunc
- Call(ctx context.Context, uri string) (response string, err error)
-}
-```
-
-## Generated Output
-
-### Go Client Library (Go/TinyGo WASM)
-
-Generated files are named `nd_host_.go` (lowercase) and placed in `$output/go/host/`. The `$output/go/` directory becomes a complete Go module (`github.com/navidrome/navidrome/plugins/pdk/go`) with package name `host`, intended for import by Navidrome plugins built with TinyGo.
-
-The generator creates:
-- `nd_host_.go` - Client wrapper code (WASM build)
-- `nd_host__stub.go` - Mock implementations for non-WASM platforms (testing)
-- `doc.go` - Package documentation listing all available services
-- `go.mod` - Go module file with required dependencies
-
-Each service file includes:
-
-- `// Code generated by ndpgen. DO NOT EDIT.` header
-- Required imports (`encoding/json`, `errors`, `github.com/extism/go-pdk`)
-- `//go:wasmimport` declarations for each host function
-- Response struct types and any struct definitions from the service
-- Wrapper functions that handle memory allocation and JSON parsing
-
-### Testing Plugins with Mocks
-
-The stub files (`*_stub.go`) contain [testify/mock](https://github.com/stretchr/testify) implementations that allow plugin authors to unit test their code on non-WASM platforms.
-
-Each host service has:
-- A private mock struct embedding `mock.Mock`
-- An exported auto-instantiated mock instance (e.g., `host.CacheMock`, `host.ArtworkMock`)
-- Wrapper functions that delegate to the mock
-
-**Example: Testing a plugin that uses the Cache service**
-
-```go
-package myplugin
-
-import (
- "testing"
-
- "github.com/navidrome/navidrome/plugins/pdk/go/host"
-)
-
-func TestMyPluginFunction(t *testing.T) {
- // Set expectations on the mock
- host.CacheMock.On("GetString", "my-key").Return("cached-value", true, nil)
- host.CacheMock.On("SetString", "new-key", "new-value", int64(3600)).Return(nil)
-
- // Call your plugin code that uses host.CacheGetString and host.CacheSetString
- result := myPluginFunction()
-
- // Assert the result
- if result != "expected" {
- t.Errorf("unexpected result: %s", result)
- }
-
- // Verify all expected calls were made
- host.CacheMock.AssertExpectations(t)
-}
-```
-
-**Resetting mocks between tests:**
-
-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.
-
-### Rust Client Library
-
-When using `-rust`, Rust client files are generated in a `rust/` subdirectory.
-
-## Supported Types
-
-ndpgen supports these Go types in method signatures:
-
-| Type | JSON Representation |
-|-------------------------------|------------------------------------------|
-| `string`, `int`, `bool`, etc. | Native JSON types |
-| `[]T` (slices) | JSON arrays |
-| `map[K]V` (maps) | JSON objects |
-| `*T` (pointers) | Nullable fields |
-| `interface{}` / `any` | Converts to `any` |
-| Custom structs | JSON objects (must be JSON-serializable) |
-
-### Multiple Return Values
-
-Methods can return multiple values (plus error):
-
-```go
-//nd:hostfunc
-Search(ctx context.Context, query string) (results []string, total int, hasMore bool, err error)
-```
-
-Generates:
-
-```go
-type ServiceSearchResponse struct {
- Results []string `json:"results,omitempty"`
- Total int `json:"total,omitempty"`
- HasMore bool `json:"hasMore,omitempty"`
- Error string `json:"error,omitempty"`
-}
-```
-
-## Running Tests
-
-```bash
-go test ./plugins/cmd/ndpgen/...
-```
diff --git a/plugins/cmd/ndpgen/go.mod b/plugins/cmd/ndpgen/go.mod
deleted file mode 100644
index 6b081a826..000000000
--- a/plugins/cmd/ndpgen/go.mod
+++ /dev/null
@@ -1,26 +0,0 @@
-module github.com/navidrome/navidrome/plugins/cmd/ndpgen
-
-go 1.25.0
-
-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
- gopkg.in/yaml.v3 v3.0.1
-)
-
-require (
- github.com/Masterminds/semver/v3 v3.4.0 // indirect
- github.com/go-logr/logr v1.4.3 // indirect
- github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
- 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
-)
diff --git a/plugins/cmd/ndpgen/go.sum b/plugins/cmd/ndpgen/go.sum
deleted file mode 100644
index 828273cab..000000000
--- a/plugins/cmd/ndpgen/go.sum
+++ /dev/null
@@ -1,75 +0,0 @@
-github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
-github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
-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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
-github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-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/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-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
-github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
-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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
-github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-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/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
-github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
-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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
-github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
-github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
-github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
-github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
-github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
-github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
-github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
-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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
-github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
-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/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-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=
-github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
-github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
-github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
-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=
-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=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go
deleted file mode 100644
index bbe886f8d..000000000
--- a/plugins/cmd/ndpgen/integration_test.go
+++ /dev/null
@@ -1,433 +0,0 @@
-package main
-
-import (
- "fmt"
- "go/format"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-// normalizeGeneratedCode normalizes generated code for comparison with expected output.
-func normalizeGeneratedCode(code string) string {
- // Replace package names (generated uses ndpdk, testdata may use ndhost)
- code = strings.ReplaceAll(code, "package ndhost", "package ndpdk")
- return code
-}
-
-var _ = Describe("ndpgen CLI", Ordered, func() {
- var (
- testDir string
- outputDir string
- ndpgenBin string
- )
-
- BeforeAll(func() {
- // Set testdata directory (relative to ndpgen root)
- testdataDir = filepath.Join(mustGetWd(GinkgoT()), "testdata")
-
- // 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()
- Expect(err).ToNot(HaveOccurred(), "Failed to build ndpgen: %s", output)
- DeferCleanup(func() {
- os.Remove(ndpgenBin)
- })
- })
-
- BeforeEach(func() {
- var err error
- testDir, err = os.MkdirTemp("", "ndpgen-test-input-*")
- Expect(err).ToNot(HaveOccurred())
- outputDir, err = os.MkdirTemp("", "ndpgen-test-output-*")
- Expect(err).ToNot(HaveOccurred())
- })
-
- AfterEach(func() {
- os.RemoveAll(testDir)
- os.RemoveAll(outputDir)
- })
-
- Describe("CLI flags and behavior", func() {
- BeforeEach(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())
- })
-
- It("supports verbose mode", func() {
- cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v")
- output, err := cmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
-
- outputStr := string(output)
- Expect(outputStr).To(ContainSubstring("Input directory:"))
- Expect(outputStr).To(ContainSubstring("Base output directory:"))
- Expect(outputStr).To(ContainSubstring("Go output directory:"))
- Expect(outputStr).To(ContainSubstring("Found 1 host service(s)"))
- Expect(outputStr).To(ContainSubstring("Generated"))
- })
-
- It("supports dry-run mode", func() {
- cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-dry-run")
- output, err := cmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
-
- Expect(string(output)).To(ContainSubstring("func TestDoAction("))
- Expect(filepath.Join(outputDir, "nd_host_test.go")).ToNot(BeAnExistingFile())
- })
-
- It("uses default package name 'host'", func() {
- customOutput, err := os.MkdirTemp("", "mypkg")
- Expect(err).ToNot(HaveOccurred())
- defer os.RemoveAll(customOutput)
-
- cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", customOutput)
- _, err = cmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred())
-
- // Go code goes to $output/go/host/
- content, err := os.ReadFile(filepath.Join(customOutput, "go", "host", "nd_host_test.go"))
- Expect(err).ToNot(HaveOccurred())
- Expect(string(content)).To(ContainSubstring("package host"))
- })
-
- It("returns error for invalid input directory", func() {
- cmd := exec.Command(ndpgenBin, "-input", "/nonexistent/path")
- output, err := cmd.CombinedOutput()
- Expect(err).To(HaveOccurred())
- Expect(string(output)).To(ContainSubstring("parsing source files"))
- })
-
- It("handles no annotated services gracefully", func() {
- Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte("package testpkg\n"), 0600)).To(Succeed())
-
- cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-v")
- output, err := cmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
- Expect(string(output)).To(ContainSubstring("No host services found"))
- })
-
- It("generates separate files for multiple services", func() {
- // Remove service.go created by BeforeEach
- Expect(os.Remove(filepath.Join(testDir, "service.go"))).To(Succeed())
-
- service1 := `package testpkg
-import "context"
-//nd:hostservice name=ServiceA permission=a
-type ServiceA interface {
- //nd:hostfunc
- MethodA(ctx context.Context) error
-}
-`
- service2 := `package testpkg
-import "context"
-//nd:hostservice name=ServiceB permission=b
-type ServiceB interface {
- //nd:hostfunc
- MethodB(ctx context.Context) error
-}
-`
- Expect(os.WriteFile(filepath.Join(testDir, "a.go"), []byte(service1), 0600)).To(Succeed())
- Expect(os.WriteFile(filepath.Join(testDir, "b.go"), []byte(service2), 0600)).To(Succeed())
-
- cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v")
- output, err := cmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
- Expect(string(output)).To(ContainSubstring("Found 2 host service(s)"))
-
- // Go code goes to $output/go/host/
- goHostDir := filepath.Join(outputDir, "go", "host")
- Expect(filepath.Join(goHostDir, "nd_host_servicea.go")).To(BeAnExistingFile())
- Expect(filepath.Join(goHostDir, "nd_host_serviceb.go")).To(BeAnExistingFile())
- })
-
- It("generates Go client code by default", func() {
- cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk")
- output, err := cmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
-
- // Go client code goes to $output/go/host/
- goHostDir := filepath.Join(outputDir, "go", "host")
- Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile())
- // Stub file also generated
- Expect(filepath.Join(goHostDir, "nd_host_test_stub.go")).To(BeAnExistingFile())
- // doc.go in host dir
- Expect(filepath.Join(goHostDir, "doc.go")).To(BeAnExistingFile())
- // go.mod at parent $output/go/ for consolidated module
- goDir := filepath.Join(outputDir, "go")
- Expect(filepath.Join(goDir, "go.mod")).To(BeAnExistingFile())
- })
- })
-
- Describe("code generation", func() {
- DescribeTable("generates correct client output",
- func(serviceFile, goClientExpectedFile, rsClientExpectedFile string) {
- serviceCode := readTestdata(serviceFile)
- goClientExpected := readTestdata(goClientExpectedFile)
- 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")
- output, err := cmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
-
- // Verify Go client code (now in $output/go/host/)
- goHostDir := filepath.Join(outputDir, "go", "host")
- entries, err := os.ReadDir(goHostDir)
- Expect(err).ToNot(HaveOccurred())
-
- var goClientFiles []string
- for _, e := range entries {
- if !e.IsDir() &&
- !strings.HasSuffix(e.Name(), "_stub.go") &&
- e.Name() != "doc.go" && e.Name() != "go.mod" {
- goClientFiles = append(goClientFiles, e.Name())
- }
- }
- Expect(goClientFiles).To(HaveLen(1), "Expected exactly one Go client file, got: %v", goClientFiles)
-
- goClientActual, err := os.ReadFile(filepath.Join(goHostDir, goClientFiles[0]))
- Expect(err).ToNot(HaveOccurred())
-
- formattedGoClientActual, err := format.Source(goClientActual)
- Expect(err).ToNot(HaveOccurred(), "Generated Go client code is not valid Go:\n%s", goClientActual)
-
- // Normalize expected code to match ndpgen output format
- normalizedExpected := normalizeGeneratedCode(goClientExpected)
- formattedGoClientExpected, err := format.Source([]byte(normalizedExpected))
- Expect(err).ToNot(HaveOccurred(), "Expected Go client code is not valid Go")
-
- Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go 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)
- Expect(err).ToNot(HaveOccurred())
- Expect(rsClientEntries).To(HaveLen(2), "Expected Rust client file and lib.rs in src/")
-
- // Find the client file (not lib.rs)
- var rsClientName string
- for _, entry := range rsClientEntries {
- if entry.Name() != "lib.rs" {
- rsClientName = entry.Name()
- break
- }
- }
- Expect(rsClientName).ToNot(BeEmpty(), "Expected to find Rust client file")
-
- rsClientActual, err := os.ReadFile(filepath.Join(rustSrcDir, rsClientName))
- Expect(err).ToNot(HaveOccurred())
-
- Expect(string(rsClientActual)).To(Equal(rsClientExpected), "Rust client code mismatch")
- },
-
- Entry("simple string params",
- "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.rs"),
-
- Entry("multiple simple params (int32)",
- "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.rs"),
-
- Entry("struct param with request type",
- "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.rs"),
-
- Entry("mixed simple and complex params",
- "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.rs"),
-
- Entry("method without error",
- "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.rs"),
-
- Entry("no params, error only",
- "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.rs"),
-
- Entry("map and interface types",
- "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.rs"),
-
- Entry("pointer types",
- "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.rs"),
-
- Entry("multiple returns",
- "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.rs"),
-
- Entry("bytes",
- "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.rs"),
-
- Entry("option pattern (value, exists bool)",
- "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.rs"),
- )
-
- It("generates the shared Go types package with -shared-types", func() {
- typesSrc := `package types
-
-// ArtistRef references an artist.
-type ArtistRef struct {
- ID string ` + "`json:\"id,omitempty\"`" + `
- Name string ` + "`json:\"name\"`" + `
-}
-`
- Expect(os.WriteFile(filepath.Join(testDir, "types.go"), []byte(typesSrc), 0600)).To(Succeed())
- cmd := exec.Command(ndpgenBin, "-shared-types", "-input", testDir, "-output", outputDir, "-go")
- out, err := cmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "Command failed: %s", out)
-
- content, err := os.ReadFile(filepath.Join(outputDir, "go", "types", "types.go"))
- Expect(err).ToNot(HaveOccurred())
- Expect(string(content)).To(ContainSubstring("package types"))
- Expect(string(content)).To(ContainSubstring("type ArtistRef struct {"))
- })
-
- It("generates compilable client code for comprehensive service", func() {
- serviceCode := readTestdata("comprehensive_service.go.txt")
-
- Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
-
- // Generate client code
- cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk")
- output, err := cmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
-
- // Go code goes to $output/go/host/
- goHostDir := filepath.Join(outputDir, "go", "host")
-
- // Read generated client code
- entries, err := os.ReadDir(goHostDir)
- Expect(err).ToNot(HaveOccurred())
-
- // Find the client file
- var clientFileName string
- for _, entry := range entries {
- name := entry.Name()
- if name != "doc.go" && name != "go.mod" && !strings.HasSuffix(name, "_stub.go") && strings.HasSuffix(name, ".go") {
- clientFileName = name
- break
- }
- }
- Expect(clientFileName).ToNot(BeEmpty(), "Expected to find Go client file")
-
- content, err := os.ReadFile(filepath.Join(goHostDir, clientFileName))
- Expect(err).ToNot(HaveOccurred())
-
- // Verify key expected content
- contentStr := string(content)
- // Should have wasmimport declarations for all methods
- Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_simpleparams"))
- Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_structparam"))
- Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noerror"))
- Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparams"))
- Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparamsnoreturns"))
-
- // Should have response types for methods with complex returns (private types in client code)
- Expect(contentStr).To(ContainSubstring("type comprehensiveSimpleParamsResponse struct"))
- Expect(contentStr).To(ContainSubstring("type comprehensiveMultipleReturnsResponse struct"))
-
- // Should have wrapper functions
- Expect(contentStr).To(ContainSubstring("func ComprehensiveSimpleParams("))
- Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParams()"))
- Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParamsNoReturns()"))
-
- // Create a plugin directory with proper import structure
- pluginDir := filepath.Join(outputDir, "plugin")
- Expect(os.MkdirAll(pluginDir, 0750)).To(Succeed())
-
- // go.mod is at parent $output/go/ for consolidated module
- goDir := filepath.Join(outputDir, "go")
-
- // Create go.mod for the plugin that imports the generated library
- goMod := fmt.Sprintf(`module testplugin
-
-go 1.25
-
-require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
-
-replace github.com/navidrome/navidrome/plugins/pdk/go => %s
-`, goDir)
- Expect(os.WriteFile(filepath.Join(pluginDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
-
- // Add a simple main function that imports and uses the ndpdk package
- mainGo := `package main
-
-import ndpdk "github.com/navidrome/navidrome/plugins/pdk/go/host"
-
-func main() {}
-
-// Use some functions to ensure import is not unused
-var _ = ndpdk.ComprehensiveNoParams
-`
- Expect(os.WriteFile(filepath.Join(pluginDir, "main.go"), []byte(mainGo), 0600)).To(Succeed())
-
- // Tidy dependencies for the generated go library
- goTidyLibCmd := exec.Command("go", "mod", "tidy")
- goTidyLibCmd.Dir = goDir
- goTidyLibOutput, err := goTidyLibCmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "go mod tidy (library) failed: %s", goTidyLibOutput)
-
- // Tidy dependencies for the plugin
- goTidyCmd := exec.Command("go", "mod", "tidy")
- goTidyCmd.Dir = pluginDir
- goTidyOutput, err := goTidyCmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "go mod tidy (plugin) failed: %s", goTidyOutput)
-
- // Build as WASM plugin - this validates the client code compiles correctly
- buildCmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", "plugin.wasm", ".")
- buildCmd.Dir = pluginDir
- buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
- buildOutput, err := buildCmd.CombinedOutput()
- Expect(err).ToNot(HaveOccurred(), "WASM build failed: %s", buildOutput)
-
- // Verify .wasm file was created
- Expect(filepath.Join(pluginDir, "plugin.wasm")).To(BeAnExistingFile())
- })
-
- })
-})
-
-var testdataDir string
-
-func readTestdata(filename string) string {
- content, err := os.ReadFile(filepath.Join(testdataDir, filename))
- Expect(err).ToNot(HaveOccurred(), "Failed to read testdata file: %s", filename)
- return string(content)
-}
-
-func mustGetWd(t FullGinkgoTInterface) string {
- dir, err := os.Getwd()
- if err != nil {
- t.Fatal(err)
- }
- // Look for ndpgen's own go.mod (the subproject root)
- for {
- goModPath := filepath.Join(dir, "go.mod")
- if _, err := os.Stat(goModPath); err == nil {
- // Check if this is the ndpgen go.mod by reading it
- content, err := os.ReadFile(goModPath)
- if err == nil && strings.Contains(string(content), "plugins/cmd/ndpgen") {
- return dir
- }
- }
- parent := filepath.Dir(dir)
- if parent == dir {
- t.Fatal("could not find ndpgen project root")
- }
- dir = parent
- }
-}
diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go
deleted file mode 100644
index bfc1b7c4e..000000000
--- a/plugins/cmd/ndpgen/internal/generator.go
+++ /dev/null
@@ -1,1024 +0,0 @@
-package internal
-
-import (
- "bytes"
- "embed"
- "fmt"
- "slices"
- "strings"
- "text/template"
-)
-
-//go:embed templates/*.tmpl
-var templatesFS embed.FS
-
-// hostFuncMap returns the template functions for host code generation.
-func hostFuncMap(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.RequestTypeName(svc.Name) },
- "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
- }
-}
-
-// clientFuncMap returns the template functions for client code generation.
-// 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,
- }
-}
-
-// 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
- var parts []string
-
- 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))
- }
- }
-
- if m.HasError {
- parts = append(parts, fmt.Sprintf("args.Error(%d)", len(m.Returns)))
- }
-
- 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{}"
-}
-
-// mockAccessor returns the testify mock accessor call for a given type and index.
-func mockAccessor(typ string, idx int) string {
- switch {
- case typ == "string":
- return fmt.Sprintf("args.String(%d)", idx)
- case typ == "bool":
- return fmt.Sprintf("args.Bool(%d)", idx)
- case typ == "int":
- return fmt.Sprintf("args.Int(%d)", idx)
- case typ == "int64":
- return fmt.Sprintf("args.Get(%d).(int64)", idx)
- case typ == "int32":
- return fmt.Sprintf("args.Get(%d).(int32)", idx)
- case typ == "float64":
- return fmt.Sprintf("args.Get(%d).(float64)", idx)
- case typ == "float32":
- return fmt.Sprintf("args.Get(%d).(float32)", idx)
- case typ == "[]byte":
- return fmt.Sprintf("args.Get(%d).([]byte)", idx)
- default:
- // For slices, maps, pointers, and custom types, use Get with type assertion
- return fmt.Sprintf("args.Get(%d).(%s)", idx, typ)
- }
-}
-
-// 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")
- if err != nil {
- return nil, fmt.Errorf("reading host template: %w", err)
- }
-
- tmpl, err := template.New("host").Funcs(hostFuncMap(svc)).Parse(string(tmplContent))
- if err != nil {
- return nil, fmt.Errorf("parsing template: %w", err)
- }
-
- data := templateData{
- Package: pkgName,
- 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
-}
-
-// GenerateClientGo generates client wrapper code for plugins to call host functions.
-func GenerateClientGo(svc Service, pkgName string) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/client.go.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading client template: %w", err)
- }
-
- tmpl, err := template.New("client").Funcs(clientFuncMap(svc)).Parse(string(tmplContent))
- if err != nil {
- return nil, fmt.Errorf("parsing template: %w", err)
- }
-
- data := templateData{
- Package: pkgName,
- 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
-}
-
-// GenerateClientGoStub generates stub code for non-WASM platforms.
-// These stubs provide type definitions and function signatures for IDE support,
-// but panic at runtime since host functions are only available in WASM plugins.
-func GenerateClientGoStub(svc Service, pkgName string) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/client_stub.go.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading client stub template: %w", err)
- }
-
- tmpl, err := template.New("client_stub").Funcs(clientFuncMap(svc)).Parse(string(tmplContent))
- if err != nil {
- return nil, fmt.Errorf("parsing template: %w", err)
- }
-
- data := templateData{
- Package: pkgName,
- 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
-}
-
-type templateData struct {
- Package string
- Service Service
-}
-
-// formatDoc formats a documentation string for Go comments.
-// It prefixes each line with "// " and trims trailing whitespace.
-func formatDoc(doc string) string {
- if doc == "" {
- return ""
- }
- lines := strings.Split(strings.TrimSpace(doc), "\n")
- var result []string
- for _, line := range lines {
- result = append(result, "// "+strings.TrimRight(line, " \t"))
- }
- return strings.Join(result, "\n")
-}
-
-// 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()) },
- "requestType": func(m Method) string { return m.RequestTypeName(svc.Name) },
- "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) },
- }
-}
-
-// GenerateClientRust generates Rust client wrapper code for plugins.
-func GenerateClientRust(svc Service) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/client.rs.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading Rust client template: %w", err)
- }
-
- tmpl, err := template.New("client_rs").Funcs(rustFuncMap(svc)).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 := 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
-}
-
-// firstLine returns the first line of a multi-line string, with the first word removed.
-func firstLine(s string) string {
- line := s
- if idx := strings.Index(s, "\n"); idx >= 0 {
- line = s[:idx]
- }
- // Remove the first word (service name like "ArtworkService")
- if idx := strings.Index(line, " "); idx >= 0 {
- line = line[idx+1:]
- }
- return line
-}
-
-// GenerateRustLib generates the lib.rs file that exposes all service modules.
-func GenerateRustLib(services []Service) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/lib.rs.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading Rust lib template: %w", err)
- }
-
- tmpl, err := template.New("lib_rs").Funcs(template.FuncMap{
- "lower": strings.ToLower,
- "firstLine": firstLine,
- }).Parse(string(tmplContent))
- if err != nil {
- return nil, fmt.Errorf("parsing template: %w", err)
- }
-
- data := struct {
- Services []Service
- }{
- Services: services,
- }
-
- var buf bytes.Buffer
- if err := tmpl.Execute(&buf, data); err != nil {
- return nil, fmt.Errorf("executing template: %w", err)
- }
-
- return buf.Bytes(), nil
-}
-
-// GenerateGoDoc generates the doc.go file that provides package documentation.
-func GenerateGoDoc(services []Service, pkgName string) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/doc.go.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading Go doc template: %w", err)
- }
-
- tmpl, err := template.New("doc_go").Funcs(template.FuncMap{
- "firstLine": firstLine,
- }).Parse(string(tmplContent))
- if err != nil {
- return nil, fmt.Errorf("parsing template: %w", err)
- }
-
- data := struct {
- Package string
- Services []Service
- }{
- Package: pkgName,
- Services: services,
- }
-
- var buf bytes.Buffer
- if err := tmpl.Execute(&buf, data); err != nil {
- return nil, fmt.Errorf("executing template: %w", err)
- }
-
- return buf.Bytes(), nil
-}
-
-// GenerateGoMod generates the go.mod file for the Go client library.
-func GenerateGoMod() ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/go.mod.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading go.mod template: %w", err)
- }
- return tmplContent, nil
-}
-
-// capabilityTemplateData holds data for capability template execution.
-type capabilityTemplateData struct {
- Package string
- Capability Capability
-}
-
-// capabilityFuncMap returns template functions for capability code generation.
-func capabilityFuncMap(cap Capability) template.FuncMap {
- return template.FuncMap{
- "formatDoc": formatDoc,
- "indent": indentText,
- "agentName": capabilityAgentName,
- "providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
- "implVar": func(e Export) string { return e.ImplVarName() },
- "exportFunc": func(e Export) string { return e.ExportFuncName() },
- }
-}
-
-// indentText adds n tabs to each line of text.
-func indentText(n int, s string) string {
- indent := strings.Repeat("\t", n)
- lines := strings.Split(s, "\n")
- for i, line := range lines {
- if line != "" {
- lines[i] = indent + line
- }
- }
- 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 {
- name := cap.Interface
- // Remove common suffixes to get a clean name
- for _, suffix := range []string{"Agent", "Callback", "Service"} {
- if strings.HasSuffix(name, suffix) {
- name = name[:len(name)-len(suffix)]
- break
- }
- }
- // Use the shortened name or the original if no suffix found
- if name == "" {
- name = cap.Interface
- }
- return name
-}
-
-// GenerateCapabilityGo generates Go export wrapper code for a capability.
-func GenerateCapabilityGo(cap Capability, pkgName string) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/capability.go.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading capability template: %w", err)
- }
-
- tmpl, err := template.New("capability").Funcs(capabilityFuncMap(cap)).Parse(string(tmplContent))
- if err != nil {
- return nil, fmt.Errorf("parsing template: %w", err)
- }
-
- data := capabilityTemplateData{
- Package: pkgName,
- Capability: cap,
- }
-
- var buf bytes.Buffer
- if err := tmpl.Execute(&buf, data); err != nil {
- return nil, fmt.Errorf("executing template: %w", err)
- }
-
- return buf.Bytes(), nil
-}
-
-// GenerateCapabilityGoStub generates stub code for non-WASM platforms.
-func GenerateCapabilityGoStub(cap Capability, pkgName string) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/capability_stub.go.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading capability stub template: %w", err)
- }
-
- tmpl, err := template.New("capability_stub").Funcs(capabilityFuncMap(cap)).Parse(string(tmplContent))
- if err != nil {
- return nil, fmt.Errorf("parsing template: %w", err)
- }
-
- data := capabilityTemplateData{
- Package: pkgName,
- Capability: cap,
- }
-
- var buf bytes.Buffer
- if err := tmpl.Execute(&buf, data); err != nil {
- return nil, fmt.Errorf("executing template: %w", err)
- }
-
- return buf.Bytes(), nil
-}
-
-// 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,
- "rustConstType": rustConstType,
- "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) },
- "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)
- },
- // 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,
- }
-}
-
-// rustTypeAlias converts a Go type to its Rust equivalent for type aliases.
-// For string types used as error sentinels/constants, we use &'static str
-// since Rust consts can't be heap-allocated String values.
-func rustTypeAlias(goType string) string {
- switch goType {
- case "string":
- return "&'static str"
- case "int", "int32":
- return "i32"
- case "int64":
- return "i64"
- default:
- return goType
- }
-}
-
-// rustConstType converts a Go type to its Rust equivalent for const declarations.
-// For String types, it returns &'static str since Rust consts can't be heap-allocated.
-func rustConstType(goType string) string {
- switch goType {
- case "string", "String":
- return "&'static str"
- case "int", "int32":
- return "i32"
- case "int64":
- return "i64"
- default:
- return goType
- }
-}
-
-// rustOutputType converts a Go type to Rust for capability method signatures.
-// It handles pointer types specially - for capability outputs, pointers become the base type
-// (not Option) because Rust's Result already provides optional semantics.
-//
-// 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, "*") {
- return goType[1:]
- }
- // Convert Go primitives to Rust primitives
- switch goType {
- case "bool":
- return "bool"
- case "string":
- return "String"
- case "int", "int32":
- return "i32"
- case "int64":
- return "i64"
- case "float32":
- return "f32"
- case "float64":
- return "f64"
- }
- return goType
-}
-
-// isPrimitiveRustType returns true if the Go type maps to a Rust primitive type.
-func isPrimitiveRustType(goType string) bool {
- // Strip pointer prefix first
- if strings.HasPrefix(goType, "*") {
- goType = goType[1:]
- }
- switch goType {
- case "bool", "string", "int", "int32", "int64", "float32", "float64":
- return true
- }
- return false
-}
-
-// rustConstName converts a Go const name to Rust convention (SCREAMING_SNAKE_CASE).
-func rustConstName(name string) string {
- return strings.ToUpper(ToSnakeCase(name))
-}
-
-// 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, "*"):
- 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":
- return "String::is_empty"
- case "bool":
- return "std::ops::Not::not"
- case "int32":
- return "is_zero_i32"
- case "uint32":
- return "is_zero_u32"
- case "int64":
- return "is_zero_i64"
- case "uint64":
- return "is_zero_u64"
- case "float32":
- return "is_zero_f32"
- case "float64":
- return "is_zero_f64"
- default:
- return "Option::is_none"
- }
-}
-
-// anyFieldUsesHashMap returns true if any field in the given structs uses a map type.
-func anyFieldUsesHashMap(structs []StructDef) bool {
- for _, st := range structs {
- for _, f := range st.Fields {
- if strings.HasPrefix(f.Type, "map[") {
- return true
- }
- }
- }
- 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 {
- // Remove common prefixes from method name
- for _, prefix := range []string{"Get", "On"} {
- if strings.HasPrefix(name, prefix) {
- name = name[len(prefix):]
- break
- }
- }
- return "register_" + ToSnakeCase(pkg) + "_" + ToSnakeCase(name)
-}
-
-// GenerateCapabilityRust generates Rust export wrapper code for a capability.
-func GenerateCapabilityRust(cap Capability) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/capability.rs.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading Rust capability template: %w", err)
- }
-
- tmpl, err := template.New("capability_rust").Funcs(rustCapabilityFuncMap(cap)).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 := capabilityTemplateData{
- Package: cap.Name,
- Capability: cap,
- }
-
- var buf bytes.Buffer
- if err := tmpl.Execute(&buf, data); err != nil {
- return nil, fmt.Errorf("executing template: %w", err)
- }
-
- return buf.Bytes(), nil
-}
-
-// GenerateCapabilityRustLib generates the lib.rs file for the Rust capabilities crate.
-func GenerateCapabilityRustLib(capabilities []Capability) ([]byte, error) {
- var buf bytes.Buffer
- buf.WriteString("// Code generated by ndpgen. DO NOT EDIT.\n\n")
- buf.WriteString("//! Navidrome Plugin Development Kit - Capability Wrappers\n")
- buf.WriteString("//!\n")
- 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)
- buf.WriteString(fmt.Sprintf("pub mod %s;\n", moduleName))
- }
-
- return buf.Bytes(), nil
-}
-
-// pdkFuncMap returns the template functions for PDK code generation.
-func pdkFuncMap() template.FuncMap {
- return template.FuncMap{
- "firstSentence": firstSentence,
- "paramList": pdkParamList,
- "returnList": pdkReturnList,
- "argList": pdkArgList,
- "argListWithReceiver": pdkArgListWithReceiver,
- "mockReturnBody": pdkMockReturnBody,
- "constValue": pdkConstValue,
- "stubTypeUnderlying": stubTypeUnderlying,
- "methodReceiver": pdkMethodReceiver,
- }
-}
-
-// stubTypeUnderlying returns the appropriate stub type for non-WASM builds.
-// For types that reference internal packages (like memory.Memory), returns "struct{}".
-func stubTypeUnderlying(t PDKType) string {
- underlying := t.Underlying
- // If the underlying type references a package (contains a dot), use a stub struct
- if strings.Contains(underlying, ".") {
- return "struct{}"
- }
- // For simple types like int, int32, return as-is
- return underlying
-}
-
-// firstSentence returns the first sentence of a doc string, normalized to a single line.
-func firstSentence(doc string) string {
- if doc == "" {
- return ""
- }
- // Normalize whitespace (replace newlines with spaces, collapse multiple spaces)
- doc = strings.Join(strings.Fields(doc), " ")
-
- // Find first period followed by space or end
- for i, r := range doc {
- if r == '.' && (i+1 >= len(doc) || doc[i+1] == ' ') {
- return doc[:i+1]
- }
- }
- return doc
-}
-
-// pdkParamList generates a parameter list string for function signature.
-func pdkParamList(params []PDKParam) string {
- var parts []string
- for _, p := range params {
- if p.Name != "" {
- parts = append(parts, p.Name+" "+p.Type)
- } else {
- parts = append(parts, p.Type)
- }
- }
- return strings.Join(parts, ", ")
-}
-
-// pdkReturnList generates a return list string for function signature.
-func pdkReturnList(returns []PDKReturn) string {
- if len(returns) == 0 {
- return ""
- }
- if len(returns) == 1 && returns[0].Name == "" {
- return " " + returns[0].Type
- }
- var parts []string
- for _, r := range returns {
- if r.Name != "" {
- parts = append(parts, r.Name+" "+r.Type)
- } else {
- parts = append(parts, r.Type)
- }
- }
- return " (" + strings.Join(parts, ", ") + ")"
-}
-
-// pdkArgList generates an argument list string for function call.
-func pdkArgList(params []PDKParam) string {
- var parts []string
- for _, p := range params {
- if p.Name != "" {
- parts = append(parts, p.Name)
- } else {
- parts = append(parts, "_")
- }
- }
- return strings.Join(parts, ", ")
-}
-
-// pdkArgListWithReceiver generates an argument list that includes the receiver variable
-// as the first argument to PDKMock.Called(). This allows tests to verify which instance
-// a method was called on.
-func pdkArgListWithReceiver(params []PDKParam, typeName string) string {
- // Use lowercase first letter of type name as receiver variable
- receiverVar := strings.ToLower(typeName[:1])
- parts := []string{receiverVar}
- for _, p := range params {
- if p.Name != "" {
- parts = append(parts, p.Name)
- } else {
- parts = append(parts, "_")
- }
- }
- return strings.Join(parts, ", ")
-}
-
-// pdkMethodReceiver generates the receiver declaration for a method.
-// Example: "r *HTTPRequest" or "m Memory"
-func pdkMethodReceiver(receiver, typeName string) string {
- receiverVar := strings.ToLower(typeName[:1])
- if strings.HasPrefix(receiver, "*") {
- return receiverVar + " *" + typeName
- }
- return receiverVar + " " + typeName
-}
-
-// pdkMockReturns generates the mock return accessors for a function.
-func pdkMockReturnBody(returns []PDKReturn) string {
- var b strings.Builder
- 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))
- }
- }
- b.WriteString("\treturn " + strings.Join(parts, ", "))
- return b.String()
-}
-
-// mockAccessorForType returns the testify mock accessor for a type.
-func mockAccessorForType(typ string, idx int) string {
- switch typ {
- case "string":
- return fmt.Sprintf("args.String(%d)", idx)
- case "bool":
- return fmt.Sprintf("args.Bool(%d)", idx)
- case "int":
- return fmt.Sprintf("args.Int(%d)", idx)
- case "error":
- return fmt.Sprintf("args.Error(%d)", idx)
- case "[]byte":
- return fmt.Sprintf("args.Get(%d).([]byte)", idx)
- case "uint64":
- return fmt.Sprintf("args.Get(%d).(uint64)", idx)
- case "uint32":
- return fmt.Sprintf("args.Get(%d).(uint32)", idx)
- case "uint16":
- return fmt.Sprintf("args.Get(%d).(uint16)", idx)
- default:
- return fmt.Sprintf("args.Get(%d).(%s)", idx, typ)
- }
-}
-
-// pdkConstValue returns the value expression for a constant.
-func pdkConstValue(c PDKConst) string {
- if c.Value == "" || c.Value == "iota" {
- return "iota"
- }
- return c.Value
-}
-
-// GeneratePDKGo generates the WASM implementation of the PDK wrapper package.
-func GeneratePDKGo(symbols *PDKSymbols) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/pdk.go.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading pdk template: %w", err)
- }
-
- tmpl, err := template.New("pdk").Funcs(pdkFuncMap()).Parse(string(tmplContent))
- if err != nil {
- return nil, fmt.Errorf("parsing template: %w", err)
- }
-
- var buf bytes.Buffer
- if err := tmpl.Execute(&buf, symbols); err != nil {
- return nil, fmt.Errorf("executing template: %w", err)
- }
-
- return buf.Bytes(), nil
-}
-
-// GeneratePDKGoStub generates the native stub implementation of the PDK wrapper package.
-func GeneratePDKGoStub(symbols *PDKSymbols) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/pdk_stub.go.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading pdk stub template: %w", err)
- }
-
- tmpl, err := template.New("pdk_stub").Funcs(pdkFuncMap()).Parse(string(tmplContent))
- if err != nil {
- return nil, fmt.Errorf("parsing template: %w", err)
- }
-
- var buf bytes.Buffer
- if err := tmpl.Execute(&buf, symbols); err != nil {
- return nil, fmt.Errorf("executing template: %w", err)
- }
-
- return buf.Bytes(), nil
-}
-
-// GeneratePDKTypesStub generates the native type definitions for the PDK wrapper package.
-func GeneratePDKTypesStub(symbols *PDKSymbols) ([]byte, error) {
- tmplContent, err := templatesFS.ReadFile("templates/types_stub.go.tmpl")
- if err != nil {
- return nil, fmt.Errorf("reading types stub template: %w", err)
- }
-
- tmpl, err := template.New("types_stub").Funcs(pdkFuncMap()).Parse(string(tmplContent))
- if err != nil {
- return nil, fmt.Errorf("parsing template: %w", err)
- }
-
- var buf bytes.Buffer
- if err := tmpl.Execute(&buf, symbols); err != nil {
- return nil, fmt.Errorf("executing template: %w", err)
- }
-
- 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
deleted file mode 100644
index 555906489..000000000
--- a/plugins/cmd/ndpgen/internal/generator_test.go
+++ /dev/null
@@ -1,1825 +0,0 @@
-package internal
-
-import (
- "go/format"
- "os"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("Generator", func() {
- Describe("GenerateHost", func() {
- It("should generate valid Go code for a simple service with strings", func() {
- // All methods use JSON request/response types
- svc := Service{
- Name: "SubsonicAPI",
- Permission: "subsonicapi",
- Interface: "SubsonicAPIService",
- Methods: []Method{
- {
- Name: "Call",
- HasError: true,
- Params: []Param{NewParam("uri", "string")},
- Returns: []Param{NewParam("response", "string")},
- },
- },
- }
-
- code, err := GenerateHost(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- // Verify the code is valid Go
- _, err = format.Source(code)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check for generated header
- Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
-
- // Check for package declaration
- Expect(codeStr).To(ContainSubstring("package host"))
-
- // All methods now use request type for JSON protocol
- Expect(codeStr).To(ContainSubstring("type SubsonicAPICallRequest struct"))
- Expect(codeStr).To(ContainSubstring(`Uri string `))
-
- // Response type with error handling
- Expect(codeStr).To(ContainSubstring("type SubsonicAPICallResponse struct"))
- Expect(codeStr).To(ContainSubstring(`Response string `))
- Expect(codeStr).To(ContainSubstring(`Error string `))
-
- // Check for registration function
- Expect(codeStr).To(ContainSubstring("func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService)"))
-
- // Check for host function name
- Expect(codeStr).To(ContainSubstring(`"subsonicapi_call"`))
-
- // Check for JSON unmarshal (all methods use JSON now)
- Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
- })
-
- It("should generate code for methods without parameters", func() {
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "NoParams",
- HasError: true,
- Returns: []Param{NewParam("result", "string")},
- },
- },
- }
-
- code, err := GenerateHost(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- _, err = format.Source(code)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
- // Methods without params don't need a request type - no params to serialize
- Expect(codeStr).NotTo(ContainSubstring("type TestNoParamsRequest struct"))
- // But still uses PTR input/output for consistency
- Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
- })
-
- It("should generate code for methods without return values", func() {
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "NoReturn",
- HasError: true,
- Params: []Param{NewParam("input", "string")},
- },
- },
- }
-
- code, err := GenerateHost(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- _, err = format.Source(code)
- Expect(err).NotTo(HaveOccurred())
- })
-
- It("should generate code for multiple methods", func() {
- svc := Service{
- Name: "Scheduler",
- Permission: "scheduler",
- Interface: "SchedulerService",
- Methods: []Method{
- {
- Name: "ScheduleRecurring",
- HasError: true,
- Params: []Param{NewParam("cronExpression", "string")},
- Returns: []Param{NewParam("scheduleID", "string")},
- },
- {
- Name: "ScheduleOneTime",
- HasError: true,
- Params: []Param{NewParam("delaySeconds", "int32")},
- Returns: []Param{NewParam("scheduleID", "string")},
- },
- {
- Name: "CancelSchedule",
- HasError: true,
- Params: []Param{NewParam("scheduleID", "string")},
- Returns: []Param{NewParam("canceled", "bool")},
- },
- },
- }
-
- code, err := GenerateHost(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- _, err = format.Source(code)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
- Expect(codeStr).To(ContainSubstring("scheduler_schedulerecurring"))
- Expect(codeStr).To(ContainSubstring("scheduler_scheduleonetime"))
- Expect(codeStr).To(ContainSubstring("scheduler_cancelschedule"))
- })
-
- It("should handle multiple simple parameters with JSON", func() {
- // All params use JSON - single PTR input
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "MultiParam",
- HasError: true,
- Params: []Param{
- NewParam("name", "string"),
- NewParam("count", "int32"),
- NewParam("enabled", "bool"),
- },
- Returns: []Param{NewParam("result", "string")},
- },
- },
- }
-
- code, err := GenerateHost(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- _, err = format.Source(code)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
- // All methods use request type with JSON protocol
- Expect(codeStr).To(ContainSubstring("type TestMultiParamRequest struct"))
- // Check for JSON unmarshal (all methods use JSON now)
- Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
- // Check that input/output ValueType both use PTR (JSON)
- Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
- })
-
- It("should use single PTR for mixed simple and complex params", func() {
- // When any param needs JSON, all are bundled into one request struct
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "MixedParam",
- HasError: true,
- Params: []Param{
- NewParam("id", "string"), // simple (PTR for string)
- NewParam("tags", "[]string"), // complex - needs JSON
- },
- Returns: []Param{NewParam("count", "int32")}, // simple
- },
- },
- }
-
- code, err := GenerateHost(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- _, err = format.Source(code)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
- // Request type IS needed because of complex param
- Expect(codeStr).To(ContainSubstring("type TestMixedParamRequest struct"))
- // When using request type, only ONE PTR for input (the JSON request)
- Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
- })
-
- It("should generate proper JSON tags for complex types", func() {
- // Complex types (structs, slices, maps) need JSON serialization
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "Method",
- HasError: true,
- Params: []Param{NewParam("inputValue", "[]string")}, // slice needs JSON
- Returns: []Param{NewParam("outputValue", "map[string]string")}, // map needs JSON
- },
- },
- }
-
- code, err := GenerateHost(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
- // Complex params need request type with JSON tags
- Expect(codeStr).To(ContainSubstring(`json:"inputValue"`))
- // Complex returns need response type with JSON tags
- Expect(codeStr).To(ContainSubstring(`json:"outputValue,omitempty"`))
- })
-
- It("should include required imports", func() {
- // Service with complex types needs JSON import
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "Method",
- HasError: true,
- Params: []Param{NewParam("data", "MyStruct")}, // struct needs JSON
- },
- },
- }
-
- code, err := GenerateHost(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
- Expect(codeStr).To(ContainSubstring(`"context"`))
- Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
- Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
- })
-
- It("should always include json import for JSON protocol", func() {
- // All services use JSON protocol, so json import is always needed
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "Method",
- Params: []Param{NewParam("count", "int32")},
- Returns: []Param{NewParam("result", "int64")},
- },
- },
- }
-
- code, err := GenerateHost(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
- Expect(codeStr).To(ContainSubstring(`"context"`))
- 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() {
- It("should convert to camelCase matching Rust serde behavior", func() {
- Expect(toJSONName("InputValue")).To(Equal("inputValue"))
- Expect(toJSONName("URI")).To(Equal("uri"))
- Expect(toJSONName("id")).To(Equal("id"))
- Expect(toJSONName("ID")).To(Equal("id"))
- Expect(toJSONName("ConnectionID")).To(Equal("connectionId"))
- Expect(toJSONName("NewConnectionID")).To(Equal("newConnectionId"))
- Expect(toJSONName("XMLHTTPRequest")).To(Equal("xmlhttpRequest"))
- Expect(toJSONName("APIKey")).To(Equal("apiKey"))
- })
-
- It("should handle empty string", func() {
- Expect(toJSONName("")).To(Equal(""))
- })
- })
-
- Describe("NewParam", func() {
- It("should create param with auto-generated JSON name", func() {
- p := NewParam("MyParam", "string")
- Expect(p.Name).To(Equal("MyParam"))
- Expect(p.Type).To(Equal("string"))
- Expect(p.JSONName).To(Equal("myParam"))
- })
- })
-
- Describe("Method.IsOptionPattern", func() {
- It("should return true for (value, exists bool) pattern", func() {
- m := Method{
- Returns: []Param{
- {Name: "value", Type: "string"},
- {Name: "exists", Type: "bool"},
- },
- }
- Expect(m.IsOptionPattern()).To(BeTrue())
- })
-
- It("should return true for (value, ok bool) pattern", func() {
- m := Method{
- Returns: []Param{
- {Name: "value", Type: "int64"},
- {Name: "ok", Type: "bool"},
- },
- }
- Expect(m.IsOptionPattern()).To(BeTrue())
- })
-
- It("should return true for (value, found bool) pattern", func() {
- m := Method{
- Returns: []Param{
- {Name: "data", Type: "[]byte"},
- {Name: "found", Type: "bool"},
- },
- }
- Expect(m.IsOptionPattern()).To(BeTrue())
- })
-
- It("should be case insensitive for bool name", func() {
- m := Method{
- Returns: []Param{
- {Name: "value", Type: "string"},
- {Name: "EXISTS", Type: "bool"},
- },
- }
- Expect(m.IsOptionPattern()).To(BeTrue())
- })
-
- It("should return false for single return", func() {
- m := Method{
- Returns: []Param{
- {Name: "value", Type: "string"},
- },
- }
- Expect(m.IsOptionPattern()).To(BeFalse())
- })
-
- It("should return false for more than two returns", func() {
- m := Method{
- Returns: []Param{
- {Name: "value", Type: "string"},
- {Name: "count", Type: "int"},
- {Name: "exists", Type: "bool"},
- },
- }
- Expect(m.IsOptionPattern()).To(BeFalse())
- })
-
- It("should return false when second return is not bool", func() {
- m := Method{
- Returns: []Param{
- {Name: "value", Type: "string"},
- {Name: "count", Type: "int"},
- },
- }
- Expect(m.IsOptionPattern()).To(BeFalse())
- })
-
- It("should return false when bool is not named exists/ok/found", func() {
- m := Method{
- Returns: []Param{
- {Name: "value", Type: "string"},
- {Name: "success", Type: "bool"},
- },
- }
- Expect(m.IsOptionPattern()).To(BeFalse())
- })
-
- It("should return false for Has() pattern where first return is bool", func() {
- // Has(key) -> (exists bool) should NOT be treated as Option pattern
- m := Method{
- Returns: []Param{
- {Name: "exists", Type: "bool"},
- },
- }
- Expect(m.IsOptionPattern()).To(BeFalse())
- })
-
- It("should return false when first return is bool (preserves Has-like methods)", func() {
- // Even with two returns, if first is bool, don't convert to Option
- m := Method{
- Returns: []Param{
- {Name: "result", Type: "bool"},
- {Name: "exists", Type: "bool"},
- },
- }
- Expect(m.IsOptionPattern()).To(BeFalse())
- })
- })
-
- 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("GenerateGoDoc", func() {
- It("should generate valid doc.go content for multiple services", func() {
- services := []Service{
- {
- Name: "Cache",
- Permission: "cache",
- Interface: "CacheService",
- Doc: "CacheService provides temporary key-value storage with TTL.",
- },
- {
- Name: "Scheduler",
- Permission: "scheduler",
- Interface: "SchedulerService",
- Doc: "SchedulerService manages scheduled tasks.",
- },
- }
-
- code, err := GenerateGoDoc(services, "ndpdk")
- Expect(err).NotTo(HaveOccurred())
-
- // Verify it's valid Go code
- _, err = format.Source(code)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check for generated header
- Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
-
- // Check for package declaration
- Expect(codeStr).To(ContainSubstring("package ndpdk"))
-
- // Check for package documentation
- Expect(codeStr).To(ContainSubstring("Package ndpdk provides Navidrome Plugin Development Kit wrappers"))
-
- // Check that services are listed
- Expect(codeStr).To(ContainSubstring("Cache:"))
- Expect(codeStr).To(ContainSubstring("Scheduler:"))
- })
- })
-
- Describe("GenerateGoMod", func() {
- It("should generate valid go.mod content", func() {
- code, err := GenerateGoMod()
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check for module declaration (consolidated PDK path at pdk/go level)
- Expect(codeStr).To(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go"))
- // Ensure it's not the old host-specific path
- Expect(codeStr).NotTo(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go/host"))
-
- // Check for Go version
- Expect(codeStr).To(ContainSubstring("go 1.25"))
-
- // Check for extism-go-pdk dependency
- Expect(codeStr).To(ContainSubstring("github.com/extism/go-pdk"))
- })
- })
-
- Describe("GenerateClientGo", func() {
- It("should include errors import when service has methods with errors", func() {
- svc := Service{
- Name: "Cache",
- Permission: "cache",
- Interface: "CacheService",
- Methods: []Method{
- {
- Name: "Get",
- HasError: true,
- Params: []Param{NewParam("key", "string")},
- Returns: []Param{NewParam("value", "string")},
- },
- },
- }
-
- code, err := GenerateClientGo(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- // Verify the code is valid Go (can't actually compile without wasip1)
- codeStr := string(code)
-
- // Check for errors import when methods have errors
- Expect(codeStr).To(ContainSubstring(`"errors"`))
- Expect(codeStr).To(ContainSubstring("errors.New"))
- })
-
- It("should not include errors import when service has no methods with errors", func() {
- svc := Service{
- Name: "Config",
- Permission: "config",
- Interface: "ConfigService",
- Methods: []Method{
- {
- Name: "Get",
- HasError: false,
- Params: []Param{NewParam("key", "string")},
- Returns: []Param{NewParam("value", "string"), NewParam("exists", "bool")},
- },
- {
- Name: "List",
- HasError: false,
- Params: []Param{NewParam("prefix", "string")},
- Returns: []Param{NewParam("keys", "[]string")},
- },
- },
- }
-
- code, err := GenerateClientGo(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check that errors is NOT imported when no methods have errors
- Expect(codeStr).NotTo(ContainSubstring(`"errors"`))
- Expect(codeStr).NotTo(ContainSubstring("errors.New"))
- })
-
- It("should generate valid Go code structure", func() {
- svc := Service{
- Name: "SubsonicAPI",
- Permission: "subsonicapi",
- Interface: "SubsonicAPIService",
- Methods: []Method{
- {
- Name: "Call",
- HasError: true,
- Params: []Param{NewParam("uri", "string")},
- Returns: []Param{NewParam("response", "string")},
- },
- },
- }
-
- code, err := GenerateClientGo(svc, "host")
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check for generated header
- Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
-
- // Check for build tag
- Expect(codeStr).To(ContainSubstring("//go:build wasip1"))
-
- // Check for package declaration
- Expect(codeStr).To(ContainSubstring("package host"))
-
- // Check for wasmimport directive
- Expect(codeStr).To(ContainSubstring("//go:wasmimport extism:host/user"))
-
- // Check for PDK import
- Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
- })
-
- })
-
- Describe("GenerateClientGoStub", func() {
- It("should generate valid mock code with testify/mock", func() {
- svc := Service{
- Name: "Cache",
- Permission: "cache",
- Interface: "CacheService",
- Doc: "CacheService provides caching capabilities.",
- Methods: []Method{
- {
- Name: "Get",
- Doc: "Get retrieves a value from the cache.",
- Params: []Param{
- {Name: "key", Type: "string"},
- },
- Returns: []Param{
- {Name: "value", Type: "string"},
- {Name: "exists", Type: "bool"},
- },
- },
- },
- }
-
- code, err := GenerateClientGoStub(svc, "ndpdk")
- Expect(err).NotTo(HaveOccurred())
-
- // Verify it's valid Go code
- _, err = format.Source(code)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check for build tag (non-WASM)
- Expect(codeStr).To(ContainSubstring("//go:build !wasip1"))
-
- // Check for package declaration
- Expect(codeStr).To(ContainSubstring("package ndpdk"))
-
- // Check for mock comment
- Expect(codeStr).To(ContainSubstring("mock implementations for non-WASM builds"))
-
- // Check for testify/mock import
- Expect(codeStr).To(ContainSubstring(`"github.com/stretchr/testify/mock"`))
-
- // Check for private mock struct
- Expect(codeStr).To(ContainSubstring("type mockCacheService struct"))
- Expect(codeStr).To(ContainSubstring("mock.Mock"))
-
- // Check for exported mock instance
- Expect(codeStr).To(ContainSubstring("var CacheMock = &mockCacheService{}"))
-
- // Check for mock method
- Expect(codeStr).To(ContainSubstring("func (m *mockCacheService) Get(key string)"))
- Expect(codeStr).To(ContainSubstring("m.Called(key)"))
-
- // Check for wrapper function delegating to mock
- Expect(codeStr).To(ContainSubstring("func CacheGet(key string)"))
- Expect(codeStr).To(ContainSubstring("return CacheMock.Get(key)"))
-
- // Stub files should NOT have request/response types (they're not needed)
- Expect(codeStr).NotTo(ContainSubstring("Request struct"))
- Expect(codeStr).NotTo(ContainSubstring("Response struct"))
- })
-
- It("should generate correct mock return values for different types", func() {
- svc := Service{
- Name: "Test",
- Permission: "test",
- Interface: "TestService",
- Methods: []Method{
- {
- Name: "GetString",
- Params: []Param{
- {Name: "key", Type: "string"},
- },
- Returns: []Param{
- {Name: "value", Type: "string"},
- },
- HasError: true,
- },
- {
- Name: "GetInt64",
- Params: []Param{
- {Name: "key", Type: "string"},
- },
- Returns: []Param{
- {Name: "value", Type: "int64"},
- {Name: "exists", Type: "bool"},
- },
- HasError: true,
- },
- {
- Name: "GetBytes",
- Params: []Param{
- {Name: "key", Type: "string"},
- },
- Returns: []Param{
- {Name: "value", Type: "[]byte"},
- },
- HasError: true,
- },
- },
- }
-
- code, err := GenerateClientGoStub(svc, "ndpdk")
- Expect(err).NotTo(HaveOccurred())
-
- // Verify it's valid Go code
- _, err = format.Source(code)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check string return uses args.String(0)
- Expect(codeStr).To(ContainSubstring("args.String(0)"))
-
- // Check int64 return uses args.Get(0).(int64)
- Expect(codeStr).To(ContainSubstring("args.Get(0).(int64)"))
-
- // 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 error returns use args.Error(N)
- Expect(codeStr).To(ContainSubstring("args.Error("))
- })
- })
-
- Describe("Integration", func() {
- It("should generate compilable code from parsed source", func() {
- // This is an integration test that verifies the full pipeline
- src := `package host
-
-import "context"
-
-// TestService is a test service.
-//nd:hostservice name=Test permission=test
-type TestService interface {
- // DoSomething does something.
- //nd:hostfunc
- DoSomething(ctx context.Context, input string) (output string, err error)
-}
-`
- // Create temporary directory
- tmpDir := GinkgoT().TempDir()
- path := tmpDir + "/test.go"
- err := writeFile(path, src)
- Expect(err).NotTo(HaveOccurred())
-
- // Parse
- services, err := ParseDirectory(tmpDir)
- Expect(err).NotTo(HaveOccurred())
- Expect(services).To(HaveLen(1))
-
- // Generate
- code, err := GenerateHost(services[0], "host")
- Expect(err).NotTo(HaveOccurred())
-
- // Format (validates syntax)
- formatted, err := format.Source(code)
- Expect(err).NotTo(HaveOccurred())
-
- // Verify key elements
- codeStr := string(formatted)
- Expect(codeStr).To(ContainSubstring("RegisterTestHostFunctions"))
- Expect(codeStr).To(ContainSubstring(`"test_dosomething"`))
- })
- })
-
- Describe("GenerateCapabilityGo", func() {
- It("should generate valid Go code for a non-required capability", func() {
- cap := Capability{
- Name: "metadata",
- Interface: "MetadataAgent",
- Required: false,
- Doc: "MetadataAgent provides metadata retrieval.",
- Methods: []Export{
- {
- Name: "GetArtistBiography",
- ExportName: "nd_get_artist_biography",
- Input: Param{Type: "ArtistInput"},
- Output: Param{Type: "ArtistBiographyOutput"},
- Doc: "Returns artist biography",
- },
- {
- Name: "GetArtistImages",
- ExportName: "nd_get_artist_images",
- Input: Param{Type: "ArtistInput"},
- Output: Param{Type: "ArtistImagesOutput"},
- Doc: "Returns artist images",
- },
- },
- Structs: []StructDef{
- {
- Name: "ArtistInput",
- Fields: []FieldDef{
- {Name: "ID", Type: "string", JSONTag: "id"},
- {Name: "Name", Type: "string", JSONTag: "name"},
- },
- },
- {
- Name: "ArtistBiographyOutput",
- Fields: []FieldDef{
- {Name: "Biography", Type: "string", JSONTag: "biography"},
- },
- },
- {
- Name: "ArtistImagesOutput",
- Fields: []FieldDef{
- {Name: "Images", Type: "[]ImageInfo", JSONTag: "images"},
- },
- },
- {
- Name: "ImageInfo",
- Fields: []FieldDef{
- {Name: "URL", Type: "string", JSONTag: "url"},
- {Name: "Size", Type: "int32", JSONTag: "size"},
- },
- },
- },
- }
-
- code, err := GenerateCapabilityGo(cap, "metadata")
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check for build tag
- Expect(codeStr).To(ContainSubstring("//go:build wasip1"))
-
- // Check for package declaration
- Expect(codeStr).To(ContainSubstring("package metadata"))
-
- // Check for marker interface (non-required)
- Expect(codeStr).To(ContainSubstring("type Metadata interface{}"))
-
- // Check for provider interfaces
- Expect(codeStr).To(ContainSubstring("type ArtistBiographyProvider interface"))
- Expect(codeStr).To(ContainSubstring("type ArtistImagesProvider interface"))
-
- // Check for Register function with type assertions
- Expect(codeStr).To(ContainSubstring("func Register(impl Metadata)"))
- Expect(codeStr).To(ContainSubstring("impl.(ArtistBiographyProvider)"))
-
- // Check for export wrappers
- Expect(codeStr).To(ContainSubstring("//go:wasmexport nd_get_artist_biography"))
- Expect(codeStr).To(ContainSubstring("func _NdGetArtistBiography()"))
-
- // Check for NotImplementedCode handling
- Expect(codeStr).To(ContainSubstring("NotImplementedCode"))
- Expect(codeStr).To(ContainSubstring("return NotImplementedCode"))
-
- // Check struct definitions
- Expect(codeStr).To(ContainSubstring("type ArtistInput struct"))
- Expect(codeStr).To(ContainSubstring("type ImageInfo struct"))
- })
-
- It("should generate valid Go code for a required capability", func() {
- cap := Capability{
- Name: "scrobbler",
- Interface: "Scrobbler",
- Required: true,
- Methods: []Export{
- {
- Name: "IsAuthorized",
- ExportName: "nd_scrobbler_is_authorized",
- Input: Param{Type: "AuthInput"},
- Output: Param{Type: "AuthOutput"},
- },
- {
- Name: "Scrobble",
- ExportName: "nd_scrobbler_scrobble",
- Input: Param{Type: "ScrobbleInput"},
- Output: Param{Type: "ScrobblerOutput"},
- },
- },
- Structs: []StructDef{
- {Name: "AuthInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
- {Name: "AuthOutput", Fields: []FieldDef{{Name: "Authorized", Type: "bool", JSONTag: "authorized"}}},
- {Name: "ScrobbleInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
- {Name: "ScrobblerOutput", Fields: []FieldDef{{Name: "Error", Type: "*string", JSONTag: "error", OmitEmpty: true}}},
- },
- }
-
- code, err := GenerateCapabilityGo(cap, "scrobbler")
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check for full interface (required capability)
- Expect(codeStr).To(ContainSubstring("type Scrobbler interface {"))
- Expect(codeStr).To(ContainSubstring("IsAuthorized(AuthInput) (AuthOutput, error)"))
- Expect(codeStr).To(ContainSubstring("Scrobble(ScrobbleInput) (ScrobblerOutput, error)"))
-
- // Should NOT have provider interfaces for required capability
- Expect(codeStr).NotTo(ContainSubstring("AuthProvider interface"))
-
- // Register should directly assign methods
- Expect(codeStr).To(ContainSubstring("func Register(impl Scrobbler)"))
- Expect(codeStr).To(ContainSubstring("impl.IsAuthorized"))
- })
-
- It("should include type aliases and consts", func() {
- cap := Capability{
- Name: "scrobbler",
- Interface: "Scrobbler",
- Required: true,
- Methods: []Export{
- {
- Name: "Scrobble",
- ExportName: "nd_scrobble",
- Input: Param{Type: "ScrobbleInput"},
- Output: Param{Type: "ScrobblerOutput"},
- },
- },
- Structs: []StructDef{
- {Name: "ScrobbleInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
- {Name: "ScrobblerOutput", Fields: []FieldDef{{Name: "ErrorType", Type: "*ScrobblerErrorType", JSONTag: "errorType", OmitEmpty: true}}},
- },
- TypeAliases: []TypeAlias{
- {Name: "ScrobblerErrorType", Type: "string", Doc: "ScrobblerErrorType indicates error handling."},
- },
- Consts: []ConstGroup{
- {
- Type: "ScrobblerErrorType",
- Values: []ConstDef{
- {Name: "ScrobblerErrorNone", Value: `"none"`, Doc: "No error"},
- {Name: "ScrobblerErrorRetry", Value: `"retry"`, Doc: "Retry later"},
- },
- },
- },
- }
-
- code, err := GenerateCapabilityGo(cap, "scrobbler")
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check type alias
- Expect(codeStr).To(ContainSubstring("type ScrobblerErrorType string"))
-
- // Check consts - all consts should have type annotation
- Expect(codeStr).To(ContainSubstring("ScrobblerErrorNone ScrobblerErrorType ="))
- Expect(codeStr).To(ContainSubstring(`"none"`))
- 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() {
- It("should generate valid stub code for non-WASM builds", func() {
- cap := Capability{
- Name: "metadata",
- Interface: "MetadataAgent",
- Required: false,
- Methods: []Export{
- {
- Name: "GetArtistBiography",
- ExportName: "nd_get_artist_biography",
- Input: Param{Type: "ArtistInput"},
- Output: Param{Type: "ArtistBiographyOutput"},
- },
- },
- Structs: []StructDef{
- {Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
- {Name: "ArtistBiographyOutput", Fields: []FieldDef{{Name: "Biography", Type: "string", JSONTag: "biography"}}},
- },
- }
-
- code, err := GenerateCapabilityGoStub(cap, "metadata")
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check for non-WASM build tag
- Expect(codeStr).To(ContainSubstring("//go:build !wasip1"))
-
- // Check for package declaration
- Expect(codeStr).To(ContainSubstring("package metadata"))
-
- // Check for no-op Register
- Expect(codeStr).To(ContainSubstring("func Register(_ Metadata) {}"))
-
- // Check struct definitions are present
- Expect(codeStr).To(ContainSubstring("type ArtistInput struct"))
-
- // Check there are no export wrappers
- Expect(codeStr).NotTo(ContainSubstring("//go:wasmexport"))
- Expect(codeStr).NotTo(ContainSubstring("pdk.InputJSON"))
- })
- })
-
- Describe("End-to-end capability generation", func() {
- It("should parse and generate capability code from source", func() {
- src := `package capabilities
-
-// Lifecycle provides plugin lifecycle hooks.
-//nd:capability name=lifecycle
-type Lifecycle interface {
- // OnInit is called when the plugin is loaded.
- //nd:export name=nd_on_init
- OnInit(OnInitInput) (OnInitOutput, error)
-}
-
-// OnInitInput is the input for OnInit.
-type OnInitInput struct {
-}
-
-// OnInitOutput is the output for OnInit.
-type OnInitOutput struct {
- // Error is the error message if initialization failed.
- Error *string ` + "`json:\"error,omitempty\"`" + `
-}
-`
- // Create temporary directory
- tmpDir := GinkgoT().TempDir()
- path := tmpDir + "/lifecycle.go"
- err := writeFile(path, src)
- Expect(err).NotTo(HaveOccurred())
-
- // Parse
- capabilities, err := ParseCapabilities(tmpDir)
- Expect(err).NotTo(HaveOccurred())
- Expect(capabilities).To(HaveLen(1))
-
- cap := capabilities[0]
- Expect(cap.Name).To(Equal("lifecycle"))
- Expect(cap.Methods).To(HaveLen(1))
-
- // Generate WASM code
- code, err := GenerateCapabilityGo(cap, "lifecycle")
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
- Expect(codeStr).To(ContainSubstring("//go:wasmexport nd_on_init"))
- Expect(codeStr).To(ContainSubstring("type InitProvider interface"))
-
- // Generate stub code
- stubCode, err := GenerateCapabilityGoStub(cap, "lifecycle")
- Expect(err).NotTo(HaveOccurred())
-
- stubStr := string(stubCode)
- Expect(stubStr).To(ContainSubstring("//go:build !wasip1"))
- Expect(stubStr).To(ContainSubstring("func Register(_ Lifecycle) {}"))
- })
- })
-})
-
-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() {
- 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"))
- })
-
- It("should return String::is_empty for string type", func() {
- Expect(skipSerializingFunc("string")).To(Equal("String::is_empty"))
- })
-
- It("should return std::ops::Not::not for bool type", func() {
- Expect(skipSerializingFunc("bool")).To(Equal("std::ops::Not::not"))
- })
-
- It("should return is_zero_* functions for numeric types", func() {
- Expect(skipSerializingFunc("int32")).To(Equal("is_zero_i32"))
- Expect(skipSerializingFunc("uint32")).To(Equal("is_zero_u32"))
- Expect(skipSerializingFunc("int64")).To(Equal("is_zero_i64"))
- Expect(skipSerializingFunc("uint64")).To(Equal("is_zero_u64"))
- Expect(skipSerializingFunc("float32")).To(Equal("is_zero_f32"))
- Expect(skipSerializingFunc("float64")).To(Equal("is_zero_f64"))
- })
-
- It("should return Option::is_none for unknown types", func() {
- Expect(skipSerializingFunc("CustomType")).To(Equal("Option::is_none"))
- })
- })
-
- Describe("rustOutputType", func() {
- It("should convert Go primitives to Rust primitives", func() {
- Expect(rustOutputType("bool")).To(Equal("bool"))
- Expect(rustOutputType("string")).To(Equal("String"))
- Expect(rustOutputType("int")).To(Equal("i32"))
- Expect(rustOutputType("int32")).To(Equal("i32"))
- Expect(rustOutputType("int64")).To(Equal("i64"))
- Expect(rustOutputType("float32")).To(Equal("f32"))
- Expect(rustOutputType("float64")).To(Equal("f64"))
- })
-
- It("should strip pointer prefix", func() {
- // NOTE: This behavior is incorrect for pointer to primitives.
- // "*string" returns "string" instead of "String", which would generate
- // invalid Rust code. No current capability uses this pattern.
- // See TODO in rustOutputType function.
- Expect(rustOutputType("*string")).To(Equal("string"))
- Expect(rustOutputType("*MyStruct")).To(Equal("MyStruct"))
- })
-
- It("should pass through unknown types", func() {
- Expect(rustOutputType("CustomType")).To(Equal("CustomType"))
- Expect(rustOutputType("MyStruct")).To(Equal("MyStruct"))
- })
- })
-
- Describe("isPrimitiveRustType", func() {
- It("should return true for primitive Go types", func() {
- Expect(isPrimitiveRustType("bool")).To(BeTrue())
- Expect(isPrimitiveRustType("string")).To(BeTrue())
- Expect(isPrimitiveRustType("int")).To(BeTrue())
- Expect(isPrimitiveRustType("int32")).To(BeTrue())
- Expect(isPrimitiveRustType("int64")).To(BeTrue())
- Expect(isPrimitiveRustType("float32")).To(BeTrue())
- Expect(isPrimitiveRustType("float64")).To(BeTrue())
- })
-
- It("should return false for non-primitive types", func() {
- Expect(isPrimitiveRustType("MyStruct")).To(BeFalse())
- Expect(isPrimitiveRustType("CustomType")).To(BeFalse())
- Expect(isPrimitiveRustType("[]string")).To(BeFalse())
- Expect(isPrimitiveRustType("map[string]int")).To(BeFalse())
- })
-
- It("should handle pointer types by stripping prefix", func() {
- Expect(isPrimitiveRustType("*string")).To(BeTrue())
- Expect(isPrimitiveRustType("*int64")).To(BeTrue())
- Expect(isPrimitiveRustType("*MyStruct")).To(BeFalse())
- })
- })
-
- Describe("GenerateCapabilityRust", func() {
- It("should generate valid Rust code with primitive output types", func() {
- cap := Capability{
- Name: "test",
- Interface: "TestAgent",
- Required: true,
- SourceFile: "test",
- Methods: []Export{
- {
- Name: "GetBool",
- ExportName: "nd_get_bool",
- Input: Param{Type: "BoolInput"},
- Output: Param{Type: "bool"},
- },
- {
- Name: "GetString",
- ExportName: "nd_get_string",
- Input: Param{Type: "StrInput"},
- Output: Param{Type: "string"},
- },
- {
- Name: "GetInt",
- ExportName: "nd_get_int",
- Input: Param{Type: "IntInput"},
- Output: Param{Type: "int32"},
- },
- },
- Structs: []StructDef{
- {Name: "BoolInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
- {Name: "StrInput", Fields: []FieldDef{{Name: "Key", Type: "string", JSONTag: "key"}}},
- {Name: "IntInput", Fields: []FieldDef{{Name: "Index", Type: "int32", JSONTag: "index"}}},
- },
- }
-
- code, err := GenerateCapabilityRust(cap)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Check that primitive output types are not prefixed with $crate::
- // The template should use isPrimitiveRust to determine this
- Expect(codeStr).To(ContainSubstring("FnResult>"))
- Expect(codeStr).To(ContainSubstring("FnResult>"))
- Expect(codeStr).To(ContainSubstring("FnResult>"))
-
- // Verify that primitive output types don't use $crate:: prefix in FnResult
- // The pattern "$crate::test::bool>" would indicate incorrect generation
- Expect(codeStr).NotTo(ContainSubstring("$crate::test::bool>"))
- Expect(codeStr).NotTo(ContainSubstring("$crate::test::String>"))
- Expect(codeStr).NotTo(ContainSubstring("$crate::test::i32>"))
- })
-
- It("should generate valid Rust code with struct output types", func() {
- cap := Capability{
- Name: "metadata",
- Interface: "MetadataAgent",
- Required: true,
- SourceFile: "metadata",
- Methods: []Export{
- {
- Name: "GetArtist",
- ExportName: "nd_get_artist",
- Input: Param{Type: "ArtistInput"},
- Output: Param{Type: "ArtistOutput"},
- },
- },
- Structs: []StructDef{
- {Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
- {Name: "ArtistOutput", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}},
- },
- }
-
- code, err := GenerateCapabilityRust(cap)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Non-primitive struct types should use $crate:: prefix
- Expect(codeStr).To(ContainSubstring("$crate::metadata::ArtistOutput"))
- })
-
- It("should generate valid Rust code with pointer output types", func() {
- cap := Capability{
- Name: "test",
- Interface: "TestAgent",
- Required: true,
- SourceFile: "test",
- Methods: []Export{
- {
- Name: "GetOptionalStruct",
- ExportName: "nd_get_optional_struct",
- Input: Param{Type: "Input"},
- Output: Param{Type: "*Output"},
- },
- },
- Structs: []StructDef{
- {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
- {Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
- },
- }
-
- code, err := GenerateCapabilityRust(cap)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Pointer to struct should strip pointer and use struct type with $crate::
- Expect(codeStr).To(ContainSubstring("$crate::test::Output>"))
- // Pointer output types should NOT have Option<> wrapping - Result handles optionality
- 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",
- Interface: "TestAgent",
- Required: true,
- SourceFile: "test",
- Methods: []Export{
- {
- Name: "GetFloat32",
- ExportName: "nd_get_float32",
- Input: Param{Type: "Input"},
- Output: Param{Type: "float32"},
- },
- {
- Name: "GetFloat64",
- ExportName: "nd_get_float64",
- Input: Param{Type: "Input"},
- Output: Param{Type: "float64"},
- },
- },
- Structs: []StructDef{
- {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
- },
- }
-
- code, err := GenerateCapabilityRust(cap)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- Expect(codeStr).To(ContainSubstring("FnResult>"))
- Expect(codeStr).To(ContainSubstring("FnResult>"))
- })
- })
-
- Describe("GenerateClientRust", func() {
- It("should generate Option for (value, exists bool) pattern", func() {
- svc := Service{
- Name: "Config",
- Permission: "config",
- Interface: "ConfigService",
- Methods: []Method{
- {
- Name: "Get",
- Params: []Param{
- {Name: "key", Type: "string", JSONName: "key"},
- },
- Returns: []Param{
- {Name: "value", Type: "string", JSONName: "value"},
- {Name: "exists", Type: "bool", JSONName: "exists"},
- },
- },
- },
- }
-
- code, err := GenerateClientRust(svc)
- Expect(err).NotTo(HaveOccurred())
-
- codeStr := string(code)
-
- // Should generate Option return type, not (String, bool)
- Expect(codeStr).To(ContainSubstring("Result