mirror of
https://github.com/navidrome/navidrome.git
synced 2026-09-12 05:30:39 -04:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9d38f842a | ||
|
|
d0d5403708 | ||
|
|
9e950cb63d | ||
|
|
2dc0983629 | ||
|
|
2aa7a5c466 | ||
|
|
4067e36a06 | ||
|
|
964d3c778b | ||
|
|
c14b598a01 | ||
|
|
25e7b5b20d | ||
|
|
bb386b13bf | ||
|
|
08eb46c8ad | ||
|
|
055fbde3cf | ||
|
|
72975a95fb | ||
|
|
e7b449b805 | ||
|
|
8d77a49b31 | ||
|
|
02c9816aec | ||
|
|
fe1c87c190 | ||
|
|
043de7a86c | ||
|
|
bea9715001 | ||
|
|
89026012ab | ||
|
|
404837799b | ||
|
|
48af781b82 |
No files matched your search
@@ -15,37 +15,9 @@ jobs:
|
||||
env:
|
||||
COVERAGE_COMMENT: 'true'
|
||||
steps:
|
||||
# The pipeline skips its coverage steps when a PR touches no Go code.
|
||||
- name: Check the run produced a coverage artifact
|
||||
id: artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.event.workflow_run.id }}/artifacts?name=octocov-pr" \
|
||||
--jq '.total_count')
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# A PR that reverts its Go changes produces no artifact, but octocov's
|
||||
# comment for the earlier head stays. Match the marker it keys off itself.
|
||||
- name: Delete the stale coverage comment
|
||||
if: steps.artifact.outputs.count == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
number=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" \
|
||||
--jq ".[] | select(.head.sha == \"$HEAD_SHA\") | .number" | head -n1)
|
||||
[ -n "$number" ] || exit 0
|
||||
id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$number/comments" \
|
||||
--jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("<!-- octocov -->"))) | .id' | head -n1)
|
||||
if [ -n "$id" ]; then
|
||||
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$id"
|
||||
fi
|
||||
|
||||
# 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
|
||||
if: steps.artifact.outputs.count != '0'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: .octocov.yml
|
||||
@@ -55,7 +27,6 @@ jobs:
|
||||
# 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
|
||||
if: steps.artifact.outputs.count != '0'
|
||||
with:
|
||||
name: octocov-pr
|
||||
path: untrusted
|
||||
@@ -64,7 +35,6 @@ jobs:
|
||||
|
||||
- name: Verify the artifact and take the coverage profile
|
||||
id: pr
|
||||
if: steps.artifact.outputs.count != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -81,7 +51,6 @@ jobs:
|
||||
echo "number=$number" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: k1LoW/octocov-action@v1
|
||||
if: steps.artifact.outputs.count != '0'
|
||||
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.
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Emits per-area change flags to $GITHUB_OUTPUT so the pipeline can skip work a
|
||||
# pull request cannot affect:
|
||||
#
|
||||
# go - Go sources, module files, linter config, embedded resources and
|
||||
# the go:generate inputs whose generated output CI verifies
|
||||
# js - anything under ui/
|
||||
# i18n - translation files and their validation script
|
||||
# build - anything that ends up in a binary, image or package (i.e. every
|
||||
# change except the doc-only paths in $DOC_ONLY_RE)
|
||||
#
|
||||
# pipeline.yml and this script are inputs to every suite they gate: a wrong
|
||||
# gating expression skips a suite green, in every run, with no other signal.
|
||||
#
|
||||
# Only pull requests are narrowed. Master pushes, tags and manual runs always get
|
||||
# every flag, so a release can never be built from a partially validated tree.
|
||||
#
|
||||
# Flags gate STEPS, not jobs: a job-level skip propagates through the needs
|
||||
# chain (actions/runner#491) and would take the release jobs down with it.
|
||||
#
|
||||
# Compares HEAD against $BASE_REF (default master). Requires full history
|
||||
# (fetch-depth: 0 in CI).
|
||||
set -uo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
GO_RE='(\.go$|(^|/)go\.(mod|sum)$|^Makefile$|^\.golangci\.yml$|^resources/|^db/migrations/|^tests/|^plugins/manifest-schema\.json$|^\.github/workflows/(pipeline\.yml|detect-changes\.sh)$)'
|
||||
JS_RE='(^ui/|^\.github/workflows/(pipeline\.yml|detect-changes\.sh)$)'
|
||||
I18N_RE='(^resources/i18n/|^ui/src/i18n/en\.json$|^\.github/workflows/validate-translations\.sh$|^\.github/workflows/(pipeline\.yml|detect-changes\.sh)$)'
|
||||
DOC_ONLY_RE='(\.md$|^LICENSE$|^\.git-blame-ignore-revs$|^\.gitignore$|^\.devcontainer/)'
|
||||
|
||||
emit() { printf '%s=%s\n' "$1" "$2" | tee -a "${GITHUB_OUTPUT:-/dev/null}"; }
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
|
||||
echo "Not a pull request — running everything."
|
||||
for area in go js i18n build; do emit "$area" true; done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BASE_REF="${BASE_REF:-master}"
|
||||
git fetch --no-tags --quiet origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" || true
|
||||
# Guard the diff, not the fetch: checkout already created the ref, so a failed
|
||||
# refresh is harmless, but an unresolvable ref would emit every flag as false.
|
||||
if ! git rev-parse --verify --quiet "origin/${BASE_REF}" >/dev/null; then
|
||||
printf '::error::Cannot resolve origin/%s. In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --no-renames: rename detection reports only the destination, so moving a file
|
||||
# out of a gated area would drop the source path from every filter.
|
||||
files="$(git diff --no-renames --name-only "origin/${BASE_REF}...HEAD")"
|
||||
echo "Changed files:"
|
||||
printf '%s\n' "$files" | sed 's/^/ /'
|
||||
echo
|
||||
|
||||
flag() { # $1=name $2=regex
|
||||
if grep -qE "$2" <<< "$files"; then emit "$1" true; else emit "$1" false; fi
|
||||
}
|
||||
|
||||
flag go "$GO_RE"
|
||||
flag js "$JS_RE"
|
||||
flag i18n "$I18N_RE"
|
||||
|
||||
if [ -n "$(grep -vE "$DOC_ONLY_RE" <<< "$files")" ]; then
|
||||
emit build true
|
||||
else
|
||||
emit build false
|
||||
fi
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Tests detect-changes.sh against a throwaway repo: builds a synthetic PR for
|
||||
# each change shape and asserts the four emitted flags.
|
||||
#
|
||||
# ./.github/workflows/detect-changes_test.sh # tests the sibling script
|
||||
# ./.github/workflows/detect-changes_test.sh <path> # tests another copy
|
||||
#
|
||||
# To confirm a case still bites, edit a pattern out of detect-changes.sh and
|
||||
# re-run: exactly the case that covers it should fail.
|
||||
set -uo pipefail
|
||||
SCRIPT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/detect-changes.sh}"
|
||||
T=$(mktemp -d); O=$(mktemp -d)
|
||||
cd "$T"
|
||||
git init -q -b master .
|
||||
git config user.email t@t; git config user.name t
|
||||
mkdir -p ui/src/i18n resources/i18n .github/workflows db/migrations
|
||||
echo x > README.md; echo x > main.go; echo x > ui/src/a.js
|
||||
echo x > resources/i18n/pt.json; echo x > ui/src/i18n/en.json; echo x > go.mod
|
||||
git add -A; git commit -qm base
|
||||
git clone -q --bare . "$O/origin.git"
|
||||
git remote add origin "$O/origin.git"
|
||||
git fetch -q origin
|
||||
|
||||
fails=0
|
||||
run() { # $1=label $2=expected "go js i18n build" ; rest=files
|
||||
label="$1"; want="$2"; shift 2
|
||||
git checkout -q -B test master
|
||||
for f in "$@"; do mkdir -p "$(dirname "$f")"; echo change >> "$f"; done
|
||||
git add -A >/dev/null; git commit -qm "$label"
|
||||
got=$(GITHUB_EVENT_NAME=pull_request BASE_REF=master bash "$SCRIPT" 2>&1 \
|
||||
| grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//')
|
||||
if [ "$got" = "$want" ]; then printf 'ok %-32s %s\n' "$label" "$got"
|
||||
else printf 'FAIL %-32s got[%s] want[%s]\n' "$label" "$got" "$want"; fails=$((fails+1)); fi
|
||||
}
|
||||
|
||||
# go js i18n build
|
||||
run "docs only" "false false false false" README.md
|
||||
run "gitignore only" "false false false false" .gitignore
|
||||
run "go only" "true false false true" core/thing.go
|
||||
run "ui only" "false true false true" ui/src/b.js
|
||||
run "i18n resources" "true false true true" resources/i18n/fr.json
|
||||
run "ui en.json" "false true true true" ui/src/i18n/en.json
|
||||
run "ui other i18n" "false true false true" ui/src/i18n/provider.js
|
||||
run "db migration sql" "true false false true" db/migrations/20260101000000_x.sql
|
||||
run "tests fixture" "true false false true" tests/fixtures/playlist.m3u
|
||||
run "tests toml" "true false false true" tests/navidrome-test.toml
|
||||
run "conf testdata" "false false false true" conf/testdata/cfg.toml
|
||||
run "manifest schema" "true false false true" plugins/manifest-schema.json
|
||||
run "other plugin json" "false false false true" plugins/testdata/fake/manifest-schema.json
|
||||
run "nested go.mod" "true false false true" plugins/testdata/x/go.mod
|
||||
run "Dockerfile only" "false false false true" Dockerfile
|
||||
run "pipeline.yml" "true true true true" .github/workflows/pipeline.yml
|
||||
run "detect-changes.sh" "true true true true" .github/workflows/detect-changes.sh
|
||||
run "other workflow" "false false false true" .github/workflows/stale.yml
|
||||
run "validate-trans.sh" "false false true true" .github/workflows/validate-translations.sh
|
||||
|
||||
echo "--- large diff must not lose flags to SIGPIPE ---"
|
||||
git checkout -q -B test master
|
||||
mkdir -p big/pkg
|
||||
python3 -c "
|
||||
import os
|
||||
os.makedirs('big/pkg', exist_ok=True)
|
||||
open('big/pkg/aaa_first.go','w').write('x')
|
||||
for i in range(2500): open('big/pkg/filler_%04d.txt' % i,'w').write('x')
|
||||
"
|
||||
git add -A >/dev/null; git commit -qm big
|
||||
nfiles=$(git diff --no-renames --name-only master...HEAD | wc -l | tr -d ' ')
|
||||
for i in 1 2 3 4 5; do
|
||||
got=$(GITHUB_EVENT_NAME=pull_request BASE_REF=master bash "$SCRIPT" 2>&1 \
|
||||
| grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//')
|
||||
if [ "$got" = "true false false true" ]; then printf 'ok %-32s %s (%s files)\n' "large diff run $i" "$got" "$nfiles"
|
||||
else printf 'FAIL %-32s got[%s] want[true false false true] (%s files)\n' "large diff run $i" "$got" "$nfiles"; fails=$((fails+1)); fi
|
||||
done
|
||||
|
||||
echo "--- renames must count the source path ---"
|
||||
rn() { # $1=label $2=expected $3=from $4=to
|
||||
git checkout -q -B test master
|
||||
mkdir -p "$(dirname "$4")"; git mv "$3" "$4"
|
||||
git add -A >/dev/null; git commit -qm "$1"
|
||||
got=$(GITHUB_EVENT_NAME=pull_request BASE_REF=master bash "$SCRIPT" 2>&1 \
|
||||
| grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//')
|
||||
if [ "$got" = "$2" ]; then printf 'ok %-32s %s\n' "$1" "$got"
|
||||
else printf 'FAIL %-32s got[%s] want[%s]\n' "$1" "$got" "$2"; fails=$((fails+1)); fi
|
||||
}
|
||||
# go js i18n build
|
||||
rn "ui .js -> docs .md" "false true false true" ui/src/a.js docs/a.js.md
|
||||
rn "go -> docs .md" "true false false true" main.go docs/main.go.md
|
||||
|
||||
echo "--- non-PR events ---"
|
||||
git checkout -q master
|
||||
for ev in push workflow_dispatch; do
|
||||
got=$(GITHUB_EVENT_NAME=$ev bash "$SCRIPT" 2>&1 | grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//')
|
||||
if [ "$got" = "true true true true" ]; then printf 'ok %-32s %s\n' "$ev" "$got"
|
||||
else printf 'FAIL %-32s got[%s]\n' "$ev" "$got"; fails=$((fails+1)); fi
|
||||
done
|
||||
|
||||
echo "--- unresolvable base ref must fail closed ---"
|
||||
git checkout -q -B test master; echo x >> main.go; git add -A >/dev/null; git commit -qm x
|
||||
out=$(GITHUB_EVENT_NAME=pull_request BASE_REF=does-not-exist bash "$SCRIPT" 2>&1); rc=$?
|
||||
if [ "$rc" != "0" ] && ! grep -qE '^(go|js|i18n|build)=' <<<"$out"; then
|
||||
printf 'ok %-32s exit=%s, no flags emitted\n' "bad base ref" "$rc"
|
||||
else
|
||||
printf 'FAIL %-32s exit=%s out[%s]\n' "bad base ref" "$rc" "$out"; fails=$((fails+1))
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "failures: $fails"
|
||||
cd /; rm -rf "$T" "$O"
|
||||
exit "$fails"
|
||||
@@ -35,27 +35,18 @@ jobs:
|
||||
|
||||
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
|
||||
const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
|
||||
const header = `Download the artifacts for this pull request:`;
|
||||
|
||||
const comments = await github.paginate(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));
|
||||
|
||||
// A PR that changes nothing reaching a binary builds nothing. Delete
|
||||
// rather than reword: the matcher above keys off the header.
|
||||
if (!downloadable.length) {
|
||||
if (existing_comment) {
|
||||
core.info(`Deleting stale comment ${existing_comment.id}`);
|
||||
await github.rest.issues.deleteComment({repo, owner, comment_id: existing_comment.id});
|
||||
}
|
||||
return core.info(`No artifacts found`);
|
||||
return core.error(`No artifacts found`);
|
||||
}
|
||||
|
||||
const header = `Download the artifacts for this pull request:`;
|
||||
let body = `${header}\n`;
|
||||
for (const art of downloadable) {
|
||||
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));
|
||||
if (existing_comment) {
|
||||
core.info(`Updating comment ${existing_comment.id}`);
|
||||
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
|
||||
|
||||
+20
-111
@@ -8,7 +8,6 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ startsWith(github.ref, 'refs/tags/v') && 'tag' || 'branch' }}-${{ github.ref }}
|
||||
@@ -59,36 +58,13 @@ jobs:
|
||||
echo "GIT_TAG=$GIT_TAG"
|
||||
echo "GIT_SHA=$GIT_SHA"
|
||||
|
||||
# Outputs gate steps, never jobs: a job-level skip propagates through the needs
|
||||
# chain (actions/runner#491) and would skip all release jobs on tag pushes.
|
||||
changes:
|
||||
name: Detect changed areas
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
go: ${{ steps.detect.outputs.go }}
|
||||
js: ${{ steps.detect.outputs.js }}
|
||||
i18n: ${{ steps.detect.outputs.i18n }}
|
||||
build: ${{ steps.detect.outputs.build }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Detect changed areas
|
||||
id: detect
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: ./.github/workflows/detect-changes.sh
|
||||
|
||||
go-lint:
|
||||
name: Lint Go code
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -96,11 +72,9 @@ jobs:
|
||||
# cannot turn red in CI just because a new golangci-lint was released.
|
||||
- name: Resolve golangci-lint version
|
||||
id: golangci-version
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: golangci-lint
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: ${{ steps.golangci-version.outputs.version }}
|
||||
@@ -108,12 +82,9 @@ jobs:
|
||||
args: --timeout 2m
|
||||
|
||||
- name: Run go goimports
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'`
|
||||
- if: needs.changes.outputs.go == 'true'
|
||||
run: go mod tidy
|
||||
- run: go mod tidy
|
||||
- name: Verify no changes from goimports and go mod tidy
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
@@ -122,10 +93,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run go generate
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
run: go generate ./...
|
||||
- name: Verify no changes from go generate
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
@@ -157,29 +126,23 @@ jobs:
|
||||
go:
|
||||
name: Test Go code
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes]
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Download dependencies
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
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
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$')
|
||||
|
||||
- name: Test ndpgen
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
run: |
|
||||
cd plugins/cmd/ndpgen
|
||||
go test -shuffle=on -v
|
||||
@@ -187,7 +150,6 @@ jobs:
|
||||
./ndpgen --help
|
||||
|
||||
- name: Upload coverage profile
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: octocov-go
|
||||
@@ -197,22 +159,18 @@ jobs:
|
||||
go-plugins:
|
||||
name: Test Go plugins
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes]
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
id: setup-go
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
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
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: plugins/testdata/.wazero-cache
|
||||
@@ -220,11 +178,9 @@ jobs:
|
||||
restore-keys: wazero-${{ runner.os }}-
|
||||
|
||||
- name: Test plugins
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/
|
||||
|
||||
- name: Upload coverage profile
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: octocov-plugins
|
||||
@@ -234,7 +190,7 @@ jobs:
|
||||
coverage:
|
||||
name: Report coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go, go-plugins]
|
||||
needs: [go, go-plugins]
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
@@ -242,31 +198,27 @@ jobs:
|
||||
COVERAGE_COMMENT: 'false'
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
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
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
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
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
|
||||
- name: Save the PR number for the comment workflow
|
||||
if: github.event_name == 'pull_request' && needs.changes.outputs.go == 'true'
|
||||
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' && needs.changes.outputs.go == 'true'
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: octocov-pr
|
||||
@@ -278,33 +230,27 @@ jobs:
|
||||
go-windows:
|
||||
name: Test Go code (Windows)
|
||||
runs-on: windows-2022
|
||||
needs: [changes]
|
||||
env:
|
||||
FFMPEG_VERSION: "7.1"
|
||||
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: msys2/setup-msys2@v2
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
with:
|
||||
msystem: MINGW64
|
||||
install: mingw-w64-x86_64-gcc
|
||||
update: false
|
||||
|
||||
- name: Add mingw64 to PATH
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
shell: bash
|
||||
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Cache ffmpeg
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
id: ffmpeg-cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
@@ -312,7 +258,7 @@ jobs:
|
||||
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
|
||||
|
||||
- name: Download ffmpeg
|
||||
if: needs.changes.outputs.go == 'true' && steps.ffmpeg-cache.outputs.cache-hit != 'true'
|
||||
if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
|
||||
@@ -324,12 +270,10 @@ jobs:
|
||||
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
|
||||
|
||||
- name: Add ffmpeg to PATH
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
shell: bash
|
||||
run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Verify toolchain
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
go version
|
||||
@@ -339,19 +283,16 @@ jobs:
|
||||
ffprobe -version
|
||||
|
||||
- name: Download dependencies
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
shell: bash
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
|
||||
|
||||
- name: Test ndpgen
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
cd plugins/cmd/ndpgen
|
||||
@@ -362,39 +303,32 @@ jobs:
|
||||
js:
|
||||
name: Test JS code
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes]
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.changes.outputs.js == 'true'
|
||||
- uses: actions/setup-node@v6
|
||||
if: needs.changes.outputs.js == 'true'
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
|
||||
- name: npm install dependencies
|
||||
if: needs.changes.outputs.js == 'true'
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
|
||||
- name: npm lint
|
||||
if: needs.changes.outputs.js == 'true'
|
||||
run: |
|
||||
cd ui
|
||||
npm run check-formatting && npm run lint
|
||||
|
||||
- name: npm test
|
||||
if: needs.changes.outputs.js == 'true'
|
||||
run: |
|
||||
cd ui
|
||||
npm test
|
||||
|
||||
- name: npm build
|
||||
if: needs.changes.outputs.js == 'true'
|
||||
run: |
|
||||
cd ui
|
||||
npm run build
|
||||
@@ -402,12 +336,9 @@ jobs:
|
||||
i18n-lint:
|
||||
name: Lint i18n files
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.changes.outputs.i18n == 'true'
|
||||
- if: needs.changes.outputs.i18n == 'true'
|
||||
run: |
|
||||
- run: |
|
||||
set -e
|
||||
for file in resources/i18n/*.json; do
|
||||
echo "Validating $file"
|
||||
@@ -419,7 +350,6 @@ jobs:
|
||||
fi
|
||||
done
|
||||
- run: ./.github/workflows/validate-translations.sh -v
|
||||
if: needs.changes.outputs.i18n == 'true'
|
||||
|
||||
|
||||
check-push-enabled:
|
||||
@@ -434,7 +364,7 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: [changes, js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
|
||||
needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||
@@ -443,23 +373,19 @@ jobs:
|
||||
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
|
||||
IS_ARMV5: ${{ matrix.platform == 'linux/arm/v5' && 'true' || 'false' }}
|
||||
IS_DOCKER_PUSH_CONFIGURED: ${{ needs.check-push-enabled.outputs.is_enabled == 'true' }}
|
||||
SHOULD_BUILD: ${{ needs.changes.outputs.build }}
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
GIT_SHA: ${{ needs.git-version.outputs.git_sha }}
|
||||
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
|
||||
steps:
|
||||
- name: Sanitize platform name
|
||||
if: env.SHOULD_BUILD == 'true'
|
||||
id: set-platform
|
||||
run: |
|
||||
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
||||
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
if: env.SHOULD_BUILD == 'true'
|
||||
|
||||
- name: Prepare Docker Buildx
|
||||
if: env.SHOULD_BUILD == 'true'
|
||||
uses: ./.github/actions/prepare-docker
|
||||
id: docker
|
||||
with:
|
||||
@@ -469,7 +395,6 @@ jobs:
|
||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Build Binaries
|
||||
if: env.SHOULD_BUILD == 'true'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -483,14 +408,14 @@ jobs:
|
||||
GIT_TAG=${{ env.GIT_TAG }}
|
||||
|
||||
- name: Set up QEMU for smoke test
|
||||
if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true'
|
||||
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.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true'
|
||||
if: env.IS_LINUX == 'true'
|
||||
run: |
|
||||
BIN=./output/${{ env.PLATFORM }}/navidrome
|
||||
chmod +x "$BIN"
|
||||
@@ -498,7 +423,6 @@ jobs:
|
||||
echo "OK: ${{ matrix.platform }} binary starts"
|
||||
|
||||
- name: Upload Binaries
|
||||
if: env.SHOULD_BUILD == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: navidrome-${{ env.PLATFORM }}
|
||||
@@ -507,7 +431,7 @@ jobs:
|
||||
|
||||
- name: Build and push image by digest
|
||||
id: push-image
|
||||
if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -522,7 +446,7 @@ jobs:
|
||||
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push-image.outputs.digest }}"
|
||||
@@ -530,7 +454,7 @@ jobs:
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v7
|
||||
if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM }}
|
||||
path: /tmp/digests/*
|
||||
@@ -543,8 +467,8 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, build, check-push-enabled]
|
||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && needs.changes.outputs.build == 'true'
|
||||
needs: [build, check-push-enabled]
|
||||
if: needs.check-push-enabled.outputs.is_enabled == 'true'
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
||||
steps:
|
||||
@@ -578,8 +502,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [changes, build, check-push-enabled]
|
||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' && needs.changes.outputs.build == 'true'
|
||||
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
|
||||
@@ -631,26 +555,22 @@ jobs:
|
||||
|
||||
msi:
|
||||
name: Build Windows installers
|
||||
needs: [changes, build, git-version]
|
||||
needs: [build, git-version]
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-windows*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Install Wix
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
run: sudo apt-get install -y wixl jq
|
||||
|
||||
- name: Build MSI
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
env:
|
||||
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
|
||||
run: |
|
||||
@@ -660,7 +580,6 @@ jobs:
|
||||
du -h binaries/msi/*.msi
|
||||
|
||||
- name: Upload MSI files
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: navidrome-windows-installers
|
||||
@@ -669,33 +588,29 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Package/Release
|
||||
needs: [changes, build, msi]
|
||||
needs: [build, msi]
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-*
|
||||
merge-multiple: true
|
||||
|
||||
- run: ls -lR ./binaries
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
|
||||
- name: Set RELEASE_FLAGS for snapshot releases
|
||||
if: needs.changes.outputs.build == 'true' && env.IS_RELEASE == 'false'
|
||||
if: env.IS_RELEASE == 'false'
|
||||
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
||||
|
||||
- name: Run GoReleaser
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: '2.16.0'
|
||||
@@ -704,20 +619,17 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Remove build artifacts
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
run: |
|
||||
ls -l ./dist
|
||||
rm ./dist/*.tar.gz ./dist/*.zip
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: dist/navidrome_0*
|
||||
|
||||
- id: set-package-list
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
name: Export list of generated packages
|
||||
run: |
|
||||
cd dist
|
||||
@@ -729,10 +641,7 @@ jobs:
|
||||
upload-packages:
|
||||
name: Upload Linux PKG
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, release]
|
||||
# Job-level skip is safe here: nothing needs this job, and fromJson would
|
||||
# error on the empty package_list a skipped release leaves behind.
|
||||
if: needs.changes.outputs.build == 'true'
|
||||
needs: [release]
|
||||
strategy:
|
||||
matrix:
|
||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||
|
||||
+1
-1
@@ -187,7 +187,7 @@ LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||
# - 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 ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||
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; \
|
||||
|
||||
@@ -132,7 +132,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, "token", token,
|
||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid,
|
||||
"requestId", middleware.GetReqID(ctx), err)
|
||||
return err
|
||||
}
|
||||
|
||||
+24
-64
@@ -2,9 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -31,7 +29,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", "", "path of backup database to restore")
|
||||
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().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
||||
_ = restoreCommand.MarkFlagRequired("backup-file")
|
||||
backupRoot.AddCommand(restoreCommand)
|
||||
@@ -78,24 +76,12 @@ func runBackup(ctx context.Context) {
|
||||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
requireExistingDB()
|
||||
|
||||
start := time.Now()
|
||||
path, err := db.Backup(ctx)
|
||||
if err != nil {
|
||||
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
|
||||
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
@@ -111,36 +97,17 @@ func runPrune(ctx context.Context) {
|
||||
conf.Server.Backup.Count = backupCount
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
|
||||
log.Warn("Prune cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
requireExistingDB()
|
||||
|
||||
start := time.Now()
|
||||
count, err := db.Prune(ctx)
|
||||
if err != nil {
|
||||
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
|
||||
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
@@ -149,36 +116,29 @@ func runPrune(ctx context.Context) {
|
||||
}
|
||||
|
||||
func runRestore(ctx context.Context) {
|
||||
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||
var path string
|
||||
requireExistingDB()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
// 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 !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
|
||||
log.Warn("Restore cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
err := db.Restore(ctx, restorePath)
|
||||
if err != nil {
|
||||
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
|
||||
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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"))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
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.")
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"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"
|
||||
@@ -15,6 +18,23 @@ import (
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
)
|
||||
|
||||
// requireExistingDB aborts the command when the database file (DbPath minus DSN
|
||||
// params) does not exist.
|
||||
func requireExistingDB() {
|
||||
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
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)
|
||||
|
||||
+5
-5
@@ -70,7 +70,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
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)
|
||||
@@ -103,7 +103,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
@@ -189,7 +189,7 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func getPluginManager() *plugins.Manager {
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.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)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
||||
@@ -42,7 +42,7 @@ var allProviders = wire.NewSet(
|
||||
lastfm.NewRouter,
|
||||
listenbrainz.NewRouter,
|
||||
events.GetBroker,
|
||||
scanner.New,
|
||||
scanner.GetInstance,
|
||||
scanner.GetWatcher,
|
||||
metrics.GetPrometheusInstance,
|
||||
db.Db,
|
||||
|
||||
@@ -407,7 +407,7 @@ func Load(noConfigDump bool) {
|
||||
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
|
||||
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
||||
}
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -329,6 +330,21 @@ var _ = Describe("Configuration", func() {
|
||||
}).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")
|
||||
|
||||
@@ -76,7 +76,7 @@ func toFastScaleType(img image.Image) image.Image {
|
||||
}
|
||||
|
||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(bytes.NewReader(data))
|
||||
original, format, err := decodeCapped(data)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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")))
|
||||
})
|
||||
})
|
||||
+11
-6
@@ -27,11 +27,12 @@ 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
|
||||
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.
|
||||
@@ -86,7 +87,11 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
|
||||
} else {
|
||||
args = buildTemplateArgs(opts)
|
||||
}
|
||||
return e.start(ctx, args)
|
||||
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) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package ffmpeg
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -684,6 +685,40 @@ var _ = Describe("ffmpeg", func() {
|
||||
})
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
})
|
||||
|
||||
It("fills in total_samples on a piped FLAC transcode", func() {
|
||||
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
Format: "flac",
|
||||
FilePath: "tests/fixtures/test.flac",
|
||||
Duration: 1, // the fixture is exactly 1s at 44100Hz
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
out, err := io.ReadAll(stream)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(out[:4])).To(Equal("fLaC"))
|
||||
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
|
||||
})
|
||||
|
||||
It("patches the duration net of the requested offset", func() {
|
||||
// The command has no %t, so ffmpeg still emits the whole fixture.
|
||||
// What is under test is the header arithmetic, not the audio.
|
||||
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
Format: "flac",
|
||||
FilePath: "tests/fixtures/test.flac",
|
||||
Duration: 3,
|
||||
Offset: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
out, err := io.ReadAll(stream)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100)))
|
||||
})
|
||||
})
|
||||
|
||||
Context("stderr capture", func() {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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 }
|
||||
+31
-14
@@ -2,6 +2,8 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -98,27 +100,19 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
|
||||
s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration))
|
||||
}
|
||||
|
||||
firstId, _, _ := strings.Cut(s.ResourceIDs, ",")
|
||||
v, err := model.GetEntityByID(r.ctx, r.ds, firstId)
|
||||
s.ResourceType, err = r.resourceType(s.ResourceIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch v.(type) {
|
||||
case *model.Artist:
|
||||
s.ResourceType = "artist"
|
||||
switch s.ResourceType {
|
||||
case "artist":
|
||||
s.Contents = r.contentsLabelFromArtist(s.ID, s.ResourceIDs)
|
||||
case *model.Album:
|
||||
s.ResourceType = "album"
|
||||
case "album":
|
||||
s.Contents = r.contentsLabelFromAlbums(s.ID, s.ResourceIDs)
|
||||
case *model.Playlist:
|
||||
s.ResourceType = "playlist"
|
||||
case "playlist":
|
||||
s.Contents = r.contentsLabelFromPlaylist(s.ID, s.ResourceIDs)
|
||||
case *model.MediaFile:
|
||||
s.ResourceType = "media_file"
|
||||
case "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, "...")
|
||||
@@ -126,6 +120,29 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return r.Persistable.Save(s)
|
||||
}
|
||||
|
||||
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(resourceIDs string) (string, error) {
|
||||
resourceType := ""
|
||||
for _, id := range strings.Split(resourceIDs, ",") {
|
||||
kind, err := model.GetEntityKindByID(r.ctx, r.ds, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !slices.Contains(shareableKinds, kind) {
|
||||
log.Error(r.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(id string, entity any, _ ...string) error {
|
||||
cols := []string{"description", "downloadable"}
|
||||
|
||||
|
||||
@@ -70,6 +70,19 @@ var _ = Describe("Share", func() {
|
||||
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(entity)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("fails when the resource IDs are of mixed types", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "123,456"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
|
||||
@@ -3,6 +3,7 @@ package local
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -18,22 +19,18 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) {
|
||||
return nil, errors.New("watcher already started")
|
||||
}
|
||||
input := make(chan notify.EventInfo, 500)
|
||||
output := make(chan string, 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)
|
||||
}
|
||||
|
||||
started := make(chan struct{})
|
||||
output := make(chan string, 500)
|
||||
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 {
|
||||
@@ -49,9 +46,5 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) {
|
||||
}
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
@@ -137,3 +137,18 @@ 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())
|
||||
})
|
||||
})
|
||||
@@ -1144,6 +1144,82 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("Player-forced format", func() {
|
||||
symfonium := func() *ClientInfo {
|
||||
return &ClientInfo{
|
||||
Name: "Symfonium",
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
It("direct plays a flac source forced to flac", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := symfonium()
|
||||
Expect(ci.ForceFormat("flac")).To(BeTrue())
|
||||
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("still transcodes a 24-bit flac when the client caps bit depth", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
|
||||
ci := symfonium()
|
||||
ci.CodecProfiles = []CodecProfile{{
|
||||
Type: CodecProfileTypeAudio, Name: "flac",
|
||||
Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}},
|
||||
}}
|
||||
Expect(ci.ForceFormat("flac")).To(BeTrue())
|
||||
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
|
||||
})
|
||||
|
||||
It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
||||
ci := symfonium()
|
||||
Expect(ci.ForceFormat("mp3")).To(BeTrue())
|
||||
ci.CapBitrate(192)
|
||||
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetBitrate).To(Equal(192))
|
||||
})
|
||||
|
||||
It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
|
||||
ci := symfonium()
|
||||
Expect(ci.ForceFormat("mp3")).To(BeTrue())
|
||||
ci.CapBitrate(192)
|
||||
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("transcodes a flac source forced to mp3", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := symfonium()
|
||||
Expect(ci.ForceFormat("mp3")).To(BeTrue())
|
||||
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetFormat).To(Equal("mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ensureProbed", func() {
|
||||
|
||||
@@ -268,6 +268,7 @@ func NewTranscodingCache() TranscodingCache {
|
||||
BitDepth: job.bitDepth,
|
||||
Channels: job.channels,
|
||||
Offset: job.offset,
|
||||
Duration: job.mf.Duration,
|
||||
})
|
||||
if err != nil {
|
||||
release()
|
||||
|
||||
+18
-8
@@ -59,28 +59,38 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool {
|
||||
return changed
|
||||
}
|
||||
|
||||
// ForceFormat narrows the client to transcoding to targetFormat and suppresses
|
||||
// direct play, but only if the client already declares a profile for that
|
||||
// format. All matching profiles are kept so negotiation can still pick among
|
||||
// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or
|
||||
// unsupported.
|
||||
// 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.
|
||||
if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) {
|
||||
matched = append(matched, ci.TranscodingProfiles[i])
|
||||
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 = nil
|
||||
ci.DirectPlayProfiles = directPlay
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ var _ = Describe("ClientInfo", func() {
|
||||
})
|
||||
|
||||
Describe("ForceFormat", func() {
|
||||
It("restricts to the forced format and clears direct play when supported", func() {
|
||||
It("restricts direct play to the forced format when supported", func() {
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
|
||||
TranscodingProfiles: []Profile{
|
||||
@@ -71,7 +71,35 @@ var _ = Describe("ClientInfo", func() {
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
||||
Expect(ci.DirectPlayProfiles).To(BeEmpty())
|
||||
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() {
|
||||
|
||||
+14
-1
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
|
||||
const (
|
||||
backupPrefix = "navidrome_backup"
|
||||
backupRegexString = backupPrefix + "_(.+)\\.db"
|
||||
backupRegexString = "^" + backupPrefix + "_(.+)\\.db$"
|
||||
)
|
||||
|
||||
var backupRegex = regexp.MustCompile(backupRegexString)
|
||||
@@ -40,6 +41,18 @@ 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)
|
||||
|
||||
@@ -5,12 +5,14 @@ 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"
|
||||
)
|
||||
@@ -103,6 +105,19 @@ 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() {
|
||||
@@ -148,4 +163,78 @@ 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -157,13 +157,24 @@ 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 {
|
||||
rows, err := db.QueryContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck
|
||||
found, err := hasGooseTable(ctx, db)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Database could not be opened!", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return !rows.Next()
|
||||
return !found
|
||||
}
|
||||
|
||||
type logAdapter struct {
|
||||
|
||||
@@ -2,6 +2,10 @@ package db
|
||||
|
||||
// Definitions for testing private methods
|
||||
var (
|
||||
EmbedMigrations = embedMigrations
|
||||
FTSTables = ftsTables
|
||||
FTSTriggerSuffixes = ftsTriggerSuffixes
|
||||
FTSSearchMigration = ftsSearchMigration
|
||||
IsSchemaEmpty = isSchemaEmpty
|
||||
BackupPath = backupPath
|
||||
OptimizeDBAt = optimizeAt
|
||||
|
||||
@@ -3,7 +3,6 @@ package migrations
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/pressly/goose/v3"
|
||||
@@ -28,10 +27,10 @@ func upAddLibraryTable(ctx context.Context, tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, 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))
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
insert into library(id, name, path) values(1, 'Music Library', ?);
|
||||
delete from property where id like 'LastScan-%';
|
||||
`, conf.Server.MusicFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
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
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,11 +3,11 @@ module github.com/navidrome/navidrome
|
||||
go 1.27
|
||||
|
||||
// Fork to implement raw tags support
|
||||
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df
|
||||
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/andybalholm/cascadia v1.3.4
|
||||
github.com/andybalholm/cascadia v1.3.5
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
|
||||
@@ -26,7 +26,7 @@ require (
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||
github.com/go-viper/encoding/ini v0.1.1
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/gohugoio/hashstructure v1.0.0
|
||||
github.com/gohugoio/hashstructure v1.1.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
|
||||
@@ -35,16 +35,16 @@ require (
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1
|
||||
github.com/kardianos/service v1.3.0
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v3 v3.2.0
|
||||
github.com/mattn/go-sqlite3 v1.14.50
|
||||
github.com/lestrrat-go/jwx/v3 v3.3.0
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.32.1
|
||||
github.com/onsi/ginkgo/v2 v2.32.2
|
||||
github.com/onsi/gomega v1.43.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.27.3
|
||||
github.com/pressly/goose/v3 v3.28.0
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
@@ -60,13 +60,13 @@ require (
|
||||
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.45.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/time v0.15.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
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -114,7 +114,7 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/prometheus/procfs v0.22.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sanity-io/litter v1.5.8 // indirect
|
||||
@@ -131,8 +131,8 @@ require (
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
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/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=
|
||||
@@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
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-20260905051825-df1d035571df h1:LdLQVAWVc6hCzqnrfVIEXOhP+r0iSit+EvsXwZDyL70=
|
||||
github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
|
||||
github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec h1:3VyOFsbsRtCQqdq/+fcmD3D6zlRvKSG7RCixgrdWfEo=
|
||||
github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
@@ -94,8 +94,8 @@ 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.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg=
|
||||
github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE=
|
||||
github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4=
|
||||
github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -134,8 +134,8 @@ github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFd
|
||||
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
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/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
@@ -159,16 +159,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
|
||||
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.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA=
|
||||
github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw=
|
||||
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.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
|
||||
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
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/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=
|
||||
@@ -185,8 +185,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
|
||||
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg=
|
||||
github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
|
||||
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
@@ -199,16 +199,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
|
||||
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
|
||||
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/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.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
|
||||
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
|
||||
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=
|
||||
@@ -304,34 +304,34 @@ 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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
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/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.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/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/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
|
||||
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
@@ -350,11 +350,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
|
||||
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
|
||||
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
|
||||
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
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=
|
||||
+4
-3
@@ -32,9 +32,6 @@ 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()
|
||||
@@ -43,6 +40,10 @@ 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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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()))
|
||||
})
|
||||
})
|
||||
@@ -72,7 +72,6 @@ func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, e
|
||||
}
|
||||
|
||||
func (r *shareRepository) loadMedia(share *model.Share) error {
|
||||
var err error
|
||||
ids := strings.Split(share.ResourceIDs, ",")
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
@@ -80,15 +79,15 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
|
||||
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.
|
||||
ctx, err := r.ownerContext(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.
|
||||
// Load as the share owner so their library access is applied.
|
||||
ctx, err := r.ownerContext(share)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
albumRepo := NewAlbumRepository(ctx, r.db)
|
||||
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"})
|
||||
if err != nil {
|
||||
@@ -98,20 +97,15 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
|
||||
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"})
|
||||
return err
|
||||
case "album":
|
||||
albumRepo := NewAlbumRepository(r.ctx, r.db)
|
||||
albumRepo := NewAlbumRepository(ctx, r.db)
|
||||
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.db)
|
||||
mfRepo := NewMediaFileRepository(ctx, r.db)
|
||||
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"})
|
||||
return err
|
||||
case "playlist":
|
||||
// Load tracks as the share owner so their library access is applied.
|
||||
ctx, err := r.ownerContext(share)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plsRepo := NewPlaylistRepository(ctx, 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
|
||||
@@ -127,7 +121,7 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
|
||||
share.Tracks = tracks.MediaFiles()
|
||||
return nil
|
||||
case "media_file":
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.db)
|
||||
mfRepo := NewMediaFileRepository(ctx, r.db)
|
||||
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})})
|
||||
share.Tracks = sortByIdPosition(tracks, ids)
|
||||
return err
|
||||
|
||||
@@ -228,7 +228,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Artist share library scoping", func() {
|
||||
Describe("Artist, album and media file share library scoping", func() {
|
||||
var otherLib model.Library
|
||||
var owner model.User
|
||||
const primaryID = "share-aa-primary"
|
||||
@@ -267,20 +267,26 @@ var _ = Describe("ShareRepository", func() {
|
||||
Expect(ur.Put(&owner)).To(Succeed())
|
||||
Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed())
|
||||
|
||||
_, 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": "art-share", "user": owner.ID, "desc": "Artist scope share",
|
||||
"type": "artist", "ids": secondaryID, "created": time.Now(), "updated": time.Now(),
|
||||
}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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 = 'art-share'`).Execute()
|
||||
_, _ = b.NewQuery(`DELETE FROM share WHERE id IN ('art-share', 'art-album-share', 'art-mf-share')`).Execute()
|
||||
mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository)
|
||||
_, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}}))
|
||||
alr := NewAlbumRepository(adminCtx, b).(*albumRepository)
|
||||
@@ -309,6 +315,23 @@ var _ = Describe("ShareRepository", func() {
|
||||
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(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("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(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("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() {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 .
|
||||
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -R "^\.worktrees" -- go run -race -tags netgo,sqlite_fts5 .
|
||||
+19
-8
@@ -93,7 +93,8 @@
|
||||
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
|
||||
"download": "Herunterladen",
|
||||
"info": "Mehr Informationen",
|
||||
"share": "Freigabe erstellen"
|
||||
"share": "Freigabe erstellen",
|
||||
"refresh": ""
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alle",
|
||||
@@ -155,11 +156,13 @@
|
||||
"newPassword": "Neues Passwort",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Letzter Zugriff am",
|
||||
"libraries": "Bibliotheken"
|
||||
"libraries": "Bibliotheken",
|
||||
"scrobbleFilter": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Die Änderung wird erst nach dem nächsten Login gültig",
|
||||
"libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken"
|
||||
"libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken",
|
||||
"scrobbleFilter": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Benutzer erstellt",
|
||||
@@ -173,7 +176,8 @@
|
||||
"adminAutoLibraries": "Administrator-Benutzer haben automatisch Zugriff auf alle Bibliotheken"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein"
|
||||
"librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein",
|
||||
"invalidScrobbleFilter": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -196,6 +200,9 @@
|
||||
"targetFormat": "Zielformat",
|
||||
"defaultBitRate": "Standardbitrate",
|
||||
"command": "Befehl"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
@@ -210,7 +217,8 @@
|
||||
"songCount": "Titelanzahl",
|
||||
"comment": "Kommentar",
|
||||
"sync": "Auto-Import",
|
||||
"path": "Importieren aus"
|
||||
"path": "Importieren aus",
|
||||
"starred": "Favorit"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Wiedergabeliste auswählen:",
|
||||
@@ -403,7 +411,8 @@
|
||||
"requiredHosts": "Benötigte Hosts",
|
||||
"configValidationError": "Validierung der Konfiguration fehlgeschlagen:",
|
||||
"schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.",
|
||||
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff."
|
||||
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff.",
|
||||
"idHelp": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "Schlüssel",
|
||||
@@ -599,7 +608,8 @@
|
||||
"coverUploaded": "Cover aktualisiert",
|
||||
"coverRemoved": "Cover entfernt",
|
||||
"coverUploadError": "Fehler beim Hochladen des Covers",
|
||||
"coverRemoveError": "Fehler beim Entfernen des Covers"
|
||||
"coverRemoveError": "Fehler beim Entfernen des Covers",
|
||||
"metadataRefreshStarted": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothek",
|
||||
@@ -634,7 +644,8 @@
|
||||
"multipleLibraries": "%{selected} von %{total} Bibliotheken",
|
||||
"selectLibraries": "Bibliotheken auswählen",
|
||||
"none": "Keine"
|
||||
}
|
||||
},
|
||||
"onlyFavourites": "Nur Favoriten anzeigen"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Warteschlange abspielen",
|
||||
|
||||
+22
-9
@@ -38,7 +38,9 @@
|
||||
"missing": "Απών",
|
||||
"libraryName": "Βιβλιοθήκη",
|
||||
"composer": "Συνθέτης",
|
||||
"disc": "Δίσκος %{discNumber}"
|
||||
"disc": "Δίσκος %{discNumber}",
|
||||
"albumGain": "Κέρδος άλμπουμ",
|
||||
"trackGain": "Κέρδος παρακολούθησης"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Αναπαραγωγη Μετα",
|
||||
@@ -91,7 +93,8 @@
|
||||
"addToPlaylist": "Προσθηκη στη λιστα αναπαραγωγης",
|
||||
"download": "Ληψη",
|
||||
"info": "Εμφάνιση Πληροφοριών",
|
||||
"share": "Μερίδιο"
|
||||
"share": "Μερίδιο",
|
||||
"refresh": "Ανανέωση μεταδεδομένων"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Όλα",
|
||||
@@ -153,11 +156,13 @@
|
||||
"newPassword": "Νέος Κωδικός Πρόσβασης",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Τελευταία Πρόσβαση",
|
||||
"libraries": "Βιβλιοθήκες"
|
||||
"libraries": "Βιβλιοθήκες",
|
||||
"scrobbleFilter": "Φίλτρο Scrobble"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση",
|
||||
"libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη"
|
||||
"libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη",
|
||||
"scrobbleFilter": "Τα τραγούδια που αντιστοιχούν σε αυτούς τους κανόνες έξυπνης λίστας αναπαραγωγής δεν αποστέλλονται στα πρόσθετα Last.fm, ListenBrainz ή scrobbler. Χρησιμοποιεί την ίδια σύνταξη και συμπεριφορά JSON με τις έξυπνες λίστες αναπαραγωγής. Παράδειγμα: {\"all\":[{\"lt\":{\"rating\":4}}]}."
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Ο χρήστης δημιουργήθηκε",
|
||||
@@ -171,7 +176,8 @@
|
||||
"adminAutoLibraries": "Οι χρήστες διαχειριστές έχουν αυτόματα πρόσβαση σε όλες τις βιβλιοθήκες"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές"
|
||||
"librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές",
|
||||
"invalidScrobbleFilter": "Πρέπει να υπάρχουν έγκυροι κανόνες έξυπνης λίστας αναπαραγωγής. Δεν υποστηρίζονται το όριο, η μετατόπιση και η καθυστέρηση ανανέωσης."
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -194,6 +200,9 @@
|
||||
"targetFormat": "Μορφη Προορισμου",
|
||||
"defaultBitRate": "Προκαθορισμένος Ρυθμός Bit",
|
||||
"command": "Εντολή"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
@@ -208,7 +217,8 @@
|
||||
"songCount": "Τραγούδια",
|
||||
"comment": "Σχόλιο",
|
||||
"sync": "Αυτόματη εισαγωγή",
|
||||
"path": "Εισαγωγή από"
|
||||
"path": "Εισαγωγή από",
|
||||
"starred": "Ευνοούμενος"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Επιλέξτε μια λίστα αναπαραγωγής:",
|
||||
@@ -401,7 +411,8 @@
|
||||
"requiredHosts": "Απαιτούμενοι hosts",
|
||||
"configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:",
|
||||
"schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.",
|
||||
"allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση."
|
||||
"allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση.",
|
||||
"idHelp": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "κλειδί",
|
||||
@@ -597,7 +608,8 @@
|
||||
"coverUploaded": "Το εξώφυλλο ενημερώθηκε",
|
||||
"coverRemoved": "Το εξώφυλλο αφαιρέθηκε",
|
||||
"coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου",
|
||||
"coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου"
|
||||
"coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου",
|
||||
"metadataRefreshStarted": "Ανανέωση μεταδεδομένων στο παρασκήνιο"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Βιβλιοθήκη",
|
||||
@@ -632,7 +644,8 @@
|
||||
"multipleLibraries": "%{selected} από %{total} Βιβλιοθήκες",
|
||||
"selectLibraries": "Επιλέξτε βιβλιοθήκες",
|
||||
"none": "Κανένα"
|
||||
}
|
||||
},
|
||||
"onlyFavourites": "Εμφάνιση μόνο αγαπημένων"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Ουρά Αναπαραγωγής",
|
||||
|
||||
+19
-8
@@ -93,7 +93,8 @@
|
||||
"addToPlaylist": "Lisää soittolistaan",
|
||||
"download": "Lataa",
|
||||
"info": "Info",
|
||||
"share": "Jaa"
|
||||
"share": "Jaa",
|
||||
"refresh": "Päivitä metatiedot"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Kaikki",
|
||||
@@ -155,11 +156,13 @@
|
||||
"newPassword": "Uusi salasana",
|
||||
"token": "Avain",
|
||||
"lastAccessAt": "Viimeisin käyttö",
|
||||
"libraries": "Kirjastot"
|
||||
"libraries": "Kirjastot",
|
||||
"scrobbleFilter": "Scrobble-suodatin"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään",
|
||||
"libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja"
|
||||
"libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja",
|
||||
"scrobbleFilter": "Älykkään soittolistan sääntöihin osumat kappaleet ohitetaan Last.fm-, ListenBrainz- ja skrobbausliitännäisissä. Käyttää samaa JSON-syntaksia ja toimintalogiikkaa kuin älykkäät soittolistat. Esimerkki: {\"all\":[{\"lt\":{\"rating\":4}}]}. Jätä tyhjäksi, jos haluat skrobata kaiken. Paikallisiin toistokertoihin tämä ei vaikuta."
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Käyttäjä luotu",
|
||||
@@ -173,7 +176,8 @@
|
||||
"adminAutoLibraries": "Admin-käyttäjillä on automaattisesti pääsy kaikkiin kirjastoihin"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille"
|
||||
"librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille",
|
||||
"invalidScrobbleFilter": "Sääntöjen pitää olla kelvollisia älykkään soittolistan sääntöjä. Rajaus (limit), siirtymä (offset) ja päivitysviive eivät toimi tässä."
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -196,6 +200,9 @@
|
||||
"targetFormat": "Kohde formaatti",
|
||||
"defaultBitRate": "Oletus bittinopeus",
|
||||
"command": "Komento"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
@@ -210,7 +217,8 @@
|
||||
"songCount": "Kappaleita",
|
||||
"comment": "Kommentti",
|
||||
"sync": "Automaattinen tuonti",
|
||||
"path": "Tuo"
|
||||
"path": "Tuo",
|
||||
"starred": "Suosikki"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Valitse soittolista:",
|
||||
@@ -403,7 +411,8 @@
|
||||
"requiredHosts": "Vaaditut palvelimet",
|
||||
"configValidationError": "Määrityksen validointi epäonnistui:",
|
||||
"schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.",
|
||||
"allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus."
|
||||
"allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus.",
|
||||
"idHelp": "Lisäosan ID, joka johdetaan sen tiedostonimestä. Käytä sitä viitatessasi tähän lisäosaan määritelmäasetuksissa, kuten agenteissa."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "avain",
|
||||
@@ -599,7 +608,8 @@
|
||||
"coverUploaded": "Kansikuva päivitetty",
|
||||
"coverRemoved": "Kansikuva poistettu",
|
||||
"coverUploadError": "Virhe ladattaessa kansikuvaa",
|
||||
"coverRemoveError": "Virhe poistettaessa kansikuvaa"
|
||||
"coverRemoveError": "Virhe poistettaessa kansikuvaa",
|
||||
"metadataRefreshStarted": "Metatietoja päivitetään taustalla"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Kirjasto",
|
||||
@@ -634,7 +644,8 @@
|
||||
"multipleLibraries": "%{selected} / %{total} kirjastoa",
|
||||
"selectLibraries": "Valitse kirjastot",
|
||||
"none": "Ei mitään"
|
||||
}
|
||||
},
|
||||
"onlyFavourites": "Näytä vain suosikit"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Jono",
|
||||
|
||||
+22
-11
@@ -93,7 +93,8 @@
|
||||
"addToPlaylist": "Engadir a Lista",
|
||||
"download": "Descargar",
|
||||
"info": "Obter info",
|
||||
"share": "Compartir"
|
||||
"share": "Compartir",
|
||||
"refresh": "Actualizar metadatos"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Todo",
|
||||
@@ -155,11 +156,13 @@
|
||||
"newPassword": "Novo contrasinal",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Último acceso",
|
||||
"libraries": "Bibliotecas"
|
||||
"libraries": "Bibliotecas",
|
||||
"scrobbleFilter": "Filtro para scrobble"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Os cambios no nome aplicaranse a próxima vez que accedas",
|
||||
"libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto"
|
||||
"libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto",
|
||||
"scrobbleFilter": "As cancións que concorden coas regras desta lista intelixente non se envían a Last.fm, ListenBrainz ou complementos similares. O filtro usa a mesma sintaxe JSON e comportamento que as listas de reprodución intelixentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixar baleiro para enviar todo. Non lle afecta ao número de reproducións locais."
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Creouse a usuaria",
|
||||
@@ -173,7 +176,8 @@
|
||||
"adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins"
|
||||
"librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins",
|
||||
"invalidScrobbleFilter": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -196,6 +200,9 @@
|
||||
"targetFormat": "Formato de destino",
|
||||
"defaultBitRate": "Taxa de bit por defecto",
|
||||
"command": "Orde"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
@@ -210,7 +217,8 @@
|
||||
"songCount": "Cancións",
|
||||
"comment": "Comentario",
|
||||
"sync": "Autoimportación",
|
||||
"path": "Importar desde"
|
||||
"path": "Importar desde",
|
||||
"starred": "Favorita"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Elixe unha lista:",
|
||||
@@ -403,7 +411,8 @@
|
||||
"requiredHosts": "Servidores requeridos",
|
||||
"configValidationError": "Fallou a comprobación da configuración:",
|
||||
"schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.",
|
||||
"allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura."
|
||||
"allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura.",
|
||||
"idHelp": "O ID do complemento, derivado do seu nome de ficheiro. Utilízao cando te refiras ao complemento nas opcións de configuración, como en Agents."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "clave",
|
||||
@@ -435,7 +444,7 @@
|
||||
"minValue": "Ten que ter polo menos %{min}",
|
||||
"maxValue": "Ten que ter %{max} ou menos",
|
||||
"number": "Ten que ser un número",
|
||||
"email": "Ten que ser un email válido",
|
||||
"email": "Ten que ser un correo válido",
|
||||
"oneOf": "Ten que ser un de: %{options}",
|
||||
"regex": "Ten que ter un formato específico (regexp): %{pattern}",
|
||||
"unique": "Ten que ser único",
|
||||
@@ -509,7 +518,7 @@
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Acerca de",
|
||||
"about": "Sobre",
|
||||
"are_you_sure": "Tes certeza?",
|
||||
"bulk_delete_content": "Tes a certeza de querer borrar a %{name} |||| Tes a certeza de querer eleminar estes %{smart_count} elementos?",
|
||||
"bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}",
|
||||
@@ -599,7 +608,8 @@
|
||||
"coverUploaded": "Subiuse a capa",
|
||||
"coverRemoved": "Retirouse a capa",
|
||||
"coverUploadError": "Erro ao subir a capa",
|
||||
"coverRemoveError": "Erro ao retirar a capa"
|
||||
"coverRemoveError": "Erro ao retirar a capa",
|
||||
"metadataRefreshStarted": "Actualizar en segundo plano os metadatos"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -626,7 +636,7 @@
|
||||
}
|
||||
},
|
||||
"albumList": "Álbums",
|
||||
"about": "Acerca de",
|
||||
"about": "Sobre",
|
||||
"playlists": "Listas de reprodución",
|
||||
"sharedPlaylists": "Listas compartidas",
|
||||
"librarySelector": {
|
||||
@@ -634,7 +644,8 @@
|
||||
"multipleLibraries": "%{selected} de %{total} Bibliotecas",
|
||||
"selectLibraries": "Seleccionar Bibliotecas",
|
||||
"none": "Ningunha"
|
||||
}
|
||||
},
|
||||
"onlyFavourites": "Mostrar só as favoritas"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Reproducir cola",
|
||||
|
||||
+34
-11
@@ -37,7 +37,10 @@
|
||||
"sampleRate": "Częstotliwość próbkowania",
|
||||
"missing": "Brak",
|
||||
"libraryName": "Biblioteka",
|
||||
"composer": "Kompozytor"
|
||||
"composer": "Kompozytor",
|
||||
"disc": "Dysk %{discNumber}",
|
||||
"albumGain": "Wzmocnienie albumu",
|
||||
"trackGain": "Wzmocnienie utworu"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Odtwarzaj Później",
|
||||
@@ -90,7 +93,8 @@
|
||||
"addToPlaylist": "Dodaj do Playlisty",
|
||||
"download": "Pobierz",
|
||||
"info": "Zdobądź Informacje",
|
||||
"share": "Udostępnij"
|
||||
"share": "Udostępnij",
|
||||
"refresh": "Odśwież Metadane"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Wszystkie",
|
||||
@@ -152,11 +156,13 @@
|
||||
"newPassword": "Nowe hasło",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Ostatnia Aktywność",
|
||||
"libraries": "Biblioteki"
|
||||
"libraries": "Biblioteki",
|
||||
"scrobbleFilter": "Filtr scrobblowania"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Zmiana nazwy będzie widoczna przy następnym logowaniu",
|
||||
"libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki"
|
||||
"libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki",
|
||||
"scrobbleFilter": "Utwory spełniające kryteria tych inteligentnych list odtwarzania nie są wysyłane do serwisów Last.fm, ListenBrainz ani do wtyczek typu scrobbler. Wykorzystywana jest tu ta sama składnia JSON i zasada działania, co w przypadku inteligentnych list odtwarzania. Przykład: {\"all\":[{\"lt\":{\"rating\":4}}]}. Pozostawienie pola pustego spowoduje scrobblowanie wszystkich utworów. Nie wpływa to na lokalne liczniki odtworzeń."
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Dodano użytkownika",
|
||||
@@ -170,7 +176,8 @@
|
||||
"adminAutoLibraries": "Administratorzy automatycznie mają dostęp do wszystkich bibliotek"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika"
|
||||
"librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika",
|
||||
"invalidScrobbleFilter": "Reguły inteligentnej listy odtwarzania muszą być poprawne. Parametry limitu, przesunięcia oraz opóźnienia odświeżania nie są obsługiwane."
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -193,6 +200,9 @@
|
||||
"targetFormat": "Format Docelowy",
|
||||
"defaultBitRate": "Domyślny Bit Rate",
|
||||
"command": "Komenda"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
@@ -207,7 +217,8 @@
|
||||
"songCount": "Liczba utworów",
|
||||
"comment": "Komentarz",
|
||||
"sync": "Import automatyczny",
|
||||
"path": "Zaimportuj z"
|
||||
"path": "Zaimportuj z",
|
||||
"starred": "Ulubione"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Wybierz playlistę:",
|
||||
@@ -353,7 +364,8 @@
|
||||
"allUsers": "Zezwalaj wszystkim użytkownikom",
|
||||
"selectedUsers": "Wybrani użytkownicy",
|
||||
"allLibraries": "Zezwalaj dla wszystkich bibliotek",
|
||||
"selectedLibraries": "Wybrane biblioteki"
|
||||
"selectedLibraries": "Wybrane biblioteki",
|
||||
"allowWriteAccess": "Zezwól na zapis"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
@@ -398,7 +410,9 @@
|
||||
"librariesRequired": "Wtyczka wymaga dostępu do informacji o bibliotece. Wybierz, dla której biblioteki zezwolić dostęp, lub włącz 'Zezwalaj dla wszystkich bibliotek'.",
|
||||
"requiredHosts": "Wymagane hosty",
|
||||
"configValidationError": "Weryfikacja konfiguracji nie powiodła się:",
|
||||
"schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy."
|
||||
"schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy.",
|
||||
"allowWriteAccessHelp": "Po włączeniu wtyczka może modyfikować pliki w katalogach bibliotek. Domyślnie wtyczki mają dostęp tylko do odczytu.",
|
||||
"idHelp": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "klucz",
|
||||
@@ -588,7 +602,14 @@
|
||||
"remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.",
|
||||
"noSimilarSongsFound": "Brak podobnych utworów",
|
||||
"noTopSongsFound": "Brak najlepszych utworów",
|
||||
"startingInstantMix": "Ładowanie Natychmiastowego Miksu..."
|
||||
"startingInstantMix": "Ładowanie Natychmiastowego Miksu...",
|
||||
"uploadCover": "Prześlij Okładkę",
|
||||
"removeCover": "Usuń Okładkę",
|
||||
"coverUploaded": "Okładka zaktualizowana",
|
||||
"coverRemoved": "Okładka usunięta",
|
||||
"coverUploadError": "Błąd przesyłania okładki",
|
||||
"coverRemoveError": "Błąd usuwania okładki",
|
||||
"metadataRefreshStarted": "Odświeżanie metadanych w tle"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteka",
|
||||
@@ -623,7 +644,8 @@
|
||||
"multipleLibraries": "%{selected} z %{total} Bibliotek",
|
||||
"selectLibraries": "Wybierz Biblioteki",
|
||||
"none": "Żadna"
|
||||
}
|
||||
},
|
||||
"onlyFavourites": "Pokaż tylko ulubione"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Kolejka Odtwarzania",
|
||||
@@ -674,7 +696,8 @@
|
||||
"exportSuccess": "Konfiguracja wyeksportowana do schowka w formacie TOML",
|
||||
"exportFailed": "Błąd kopiowania konfiguracji",
|
||||
"devFlagsHeader": "Flagi Rozwojowe (mogą ulec zmianie/usunięciu)",
|
||||
"devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach"
|
||||
"devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach",
|
||||
"downloadToml": "Konfiguracja Pobierania (TOML)"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
"rawTags": "Tags originais",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"sampleRate": "Taxa de amostragem",
|
||||
"albumGain": "Ganho do álbum",
|
||||
"trackGain": "Ganho da faixa",
|
||||
"missing": "Ausente",
|
||||
"libraryName": "Biblioteca",
|
||||
"composer": "Compositor",
|
||||
"disc": "Disco %{discNumber}"
|
||||
"disc": "Disco %{discNumber}",
|
||||
"albumGain": "Ganho do álbum",
|
||||
"trackGain": "Ganho da faixa"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
@@ -93,8 +93,8 @@
|
||||
"addToPlaylist": "Adicionar à playlist",
|
||||
"download": "Baixar",
|
||||
"info": "Detalhes",
|
||||
"refresh": "Atualizar Metadados",
|
||||
"share": "Compartilhar"
|
||||
"share": "Compartilhar",
|
||||
"refresh": "Atualizar Metadados"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Todos",
|
||||
@@ -200,6 +200,9 @@
|
||||
"targetFormat": "Formato",
|
||||
"defaultBitRate": "Bitrate padrão",
|
||||
"command": "Comando"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
@@ -214,7 +217,8 @@
|
||||
"songCount": "Músicas",
|
||||
"comment": "Comentário",
|
||||
"sync": "Auto-importar",
|
||||
"path": "Importar de"
|
||||
"path": "Importar de",
|
||||
"starred": "Favorita"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Selecione a playlist:",
|
||||
@@ -394,7 +398,6 @@
|
||||
"invalidJson": "A configuração deve ser um JSON válido"
|
||||
},
|
||||
"messages": {
|
||||
"idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents.",
|
||||
"configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.",
|
||||
"clickPermissions": "Clique em uma permissão para ver detalhes",
|
||||
"noConfig": "Nenhuma configuração definida",
|
||||
@@ -408,7 +411,8 @@
|
||||
"requiredHosts": "Hosts necessários",
|
||||
"configValidationError": "Falha na validação da configuração:",
|
||||
"schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.",
|
||||
"allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura."
|
||||
"allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.",
|
||||
"idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "chave",
|
||||
@@ -640,7 +644,8 @@
|
||||
"multipleLibraries": "%{selected} de %{total} Bibliotecas",
|
||||
"selectLibraries": "Selecionar Bibliotecas",
|
||||
"none": "Nenhuma"
|
||||
}
|
||||
},
|
||||
"onlyFavourites": "Somente favoritas"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Fila de Execução",
|
||||
|
||||
+19
-8
@@ -93,7 +93,8 @@
|
||||
"addToPlaylist": "เพิ่มลงในเพลย์ลิสต์",
|
||||
"download": "ดาวน์โหลด",
|
||||
"info": "ดูรายละเอียด",
|
||||
"share": "แบ่งปัน"
|
||||
"share": "แบ่งปัน",
|
||||
"refresh": ""
|
||||
},
|
||||
"lists": {
|
||||
"all": "ทั้งหมด",
|
||||
@@ -155,11 +156,13 @@
|
||||
"newPassword": "รหัสผ่านใหม่",
|
||||
"token": "โทเคน",
|
||||
"lastAccessAt": "เข้าใช้ล่าสุด",
|
||||
"libraries": "ห้องสมุด"
|
||||
"libraries": "ห้องสมุด",
|
||||
"scrobbleFilter": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป",
|
||||
"libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น"
|
||||
"libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น",
|
||||
"scrobbleFilter": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "สร้างชื่อผู้ใช้",
|
||||
@@ -173,7 +176,8 @@
|
||||
"adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล"
|
||||
"librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล",
|
||||
"invalidScrobbleFilter": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -196,6 +200,9 @@
|
||||
"targetFormat": "ชนิดไฟล์เสียง",
|
||||
"defaultBitRate": "บิตเรท",
|
||||
"command": "คำสั่ง"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
@@ -210,7 +217,8 @@
|
||||
"songCount": "เพลง",
|
||||
"comment": "ความคิดเห็น",
|
||||
"sync": "นำเข้าอัตโนมัติ",
|
||||
"path": "นำเข้าจาก"
|
||||
"path": "นำเข้าจาก",
|
||||
"starred": "ชื่นชอบ"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "เลือกเพลย์ลิสต์",
|
||||
@@ -403,7 +411,8 @@
|
||||
"requiredHosts": "ต้องการ Host",
|
||||
"configValidationError": "การตั้งค่าเกิดความผิดพลาด",
|
||||
"schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน",
|
||||
"allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น"
|
||||
"allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น",
|
||||
"idHelp": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "คีย์",
|
||||
@@ -599,7 +608,8 @@
|
||||
"coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว",
|
||||
"coverRemoved": "ภาพหน้าปกถูกลบแล้ว",
|
||||
"coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด",
|
||||
"coverRemoveError": "ลบภาพหน้าปกผิดพลาด"
|
||||
"coverRemoveError": "ลบภาพหน้าปกผิดพลาด",
|
||||
"metadataRefreshStarted": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "ห้องสมุดเพลง",
|
||||
@@ -634,7 +644,8 @@
|
||||
"multipleLibraries": "%{selected} ของ %{total} ห้องสมุด",
|
||||
"selectLibraries": "เลือกห้องสมุด",
|
||||
"none": "ไม่มี"
|
||||
}
|
||||
},
|
||||
"onlyFavourites": "แสดงเฉพาะที่ชื่นชอบ"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "คิวเล่น",
|
||||
|
||||
+22
-9
@@ -38,7 +38,9 @@
|
||||
"missing": "Поле відсутнє",
|
||||
"libraryName": "Бібліотека",
|
||||
"composer": "Композитор",
|
||||
"disc": "Диск %{discNumber}"
|
||||
"disc": "Диск %{discNumber}",
|
||||
"albumGain": "",
|
||||
"trackGain": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Прослухати пізніше",
|
||||
@@ -91,7 +93,8 @@
|
||||
"addToPlaylist": "Додати у список відтворення",
|
||||
"download": "Завантажити",
|
||||
"info": "Отримати інформацію",
|
||||
"share": "Поширити"
|
||||
"share": "Поширити",
|
||||
"refresh": ""
|
||||
},
|
||||
"lists": {
|
||||
"all": "Усі",
|
||||
@@ -153,11 +156,13 @@
|
||||
"newPassword": "Новий пароль",
|
||||
"token": "Токен",
|
||||
"lastAccessAt": "Останній доступ",
|
||||
"libraries": "Бібліотеки"
|
||||
"libraries": "Бібліотеки",
|
||||
"scrobbleFilter": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Змінене ім'я буде відображатися при наступній авторизації",
|
||||
"libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням"
|
||||
"libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням",
|
||||
"scrobbleFilter": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Користувача створено",
|
||||
@@ -171,7 +176,8 @@
|
||||
"adminAutoLibraries": "Користувачі-адміністратори автоматично отримують доступ до всіх бібліотек"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека"
|
||||
"librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека",
|
||||
"invalidScrobbleFilter": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -194,6 +200,9 @@
|
||||
"targetFormat": "Цільовий формат",
|
||||
"defaultBitRate": "Швидкість передачі бітів за замовчуванням",
|
||||
"command": "Команда"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
@@ -208,7 +217,8 @@
|
||||
"songCount": "Пісні",
|
||||
"comment": "Коментар",
|
||||
"sync": "Автоімпорт",
|
||||
"path": "Імпортувати із"
|
||||
"path": "Імпортувати із",
|
||||
"starred": "Улюблене"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Вибрати список відтворення:",
|
||||
@@ -401,7 +411,8 @@
|
||||
"requiredHosts": "Обов'язкові хости",
|
||||
"configValidationError": "Перевірка конфігурації зазнала невдачі:",
|
||||
"schemaRenderError": "Неможливо відобразити форму конфігурації. Схема плагіна може бути недійсною.",
|
||||
"allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання."
|
||||
"allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання.",
|
||||
"idHelp": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "ключ",
|
||||
@@ -597,7 +608,8 @@
|
||||
"coverUploaded": "Обкладинку оновлено",
|
||||
"coverRemoved": "Обкладинка видалена",
|
||||
"coverUploadError": "Помилка завантаження обкладинки",
|
||||
"coverRemoveError": "Помилка видалення обкладинки"
|
||||
"coverRemoveError": "Помилка видалення обкладинки",
|
||||
"metadataRefreshStarted": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Бібліотека",
|
||||
@@ -632,7 +644,8 @@
|
||||
"multipleLibraries": "%{selected} з %{total} Бібліотеки",
|
||||
"selectLibraries": "Вибір бібліотек",
|
||||
"none": "Відсутня"
|
||||
}
|
||||
},
|
||||
"onlyFavourites": "Показати улюблене"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Грати по черзі",
|
||||
|
||||
@@ -93,7 +93,8 @@
|
||||
"addToPlaylist": "加入至播放清單",
|
||||
"download": "下載",
|
||||
"info": "取得資訊",
|
||||
"share": "分享"
|
||||
"share": "分享",
|
||||
"refresh": ""
|
||||
},
|
||||
"lists": {
|
||||
"all": "所有",
|
||||
@@ -155,11 +156,13 @@
|
||||
"newPassword": "新密碼",
|
||||
"token": "權杖",
|
||||
"lastAccessAt": "上次存取",
|
||||
"libraries": "媒體庫"
|
||||
"libraries": "媒體庫",
|
||||
"scrobbleFilter": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "您的名稱會在下次登入時生效",
|
||||
"libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫"
|
||||
"libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫",
|
||||
"scrobbleFilter": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "使用者已建立",
|
||||
@@ -173,7 +176,8 @@
|
||||
"adminAutoLibraries": "管理員預設可存取所有媒體庫"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
|
||||
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫",
|
||||
"invalidScrobbleFilter": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -196,6 +200,9 @@
|
||||
"targetFormat": "目標格式",
|
||||
"defaultBitRate": "預設位元率",
|
||||
"command": "指令"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": ""
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
@@ -210,7 +217,8 @@
|
||||
"songCount": "歌曲數",
|
||||
"comment": "註解",
|
||||
"sync": "自動匯入",
|
||||
"path": "匯入來源"
|
||||
"path": "匯入來源",
|
||||
"starred": "收藏"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "選取播放清單:",
|
||||
@@ -403,7 +411,8 @@
|
||||
"requiredHosts": "必要的 Hosts",
|
||||
"configValidationError": "設定驗證失敗:",
|
||||
"schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。",
|
||||
"allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。"
|
||||
"allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。",
|
||||
"idHelp": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "鍵",
|
||||
@@ -599,7 +608,8 @@
|
||||
"coverUploaded": "已更新封面圖",
|
||||
"coverRemoved": "已移除封面圖",
|
||||
"coverUploadError": "上傳封面圖時發生錯誤",
|
||||
"coverRemoveError": "移除封面圖時發生錯誤"
|
||||
"coverRemoveError": "移除封面圖時發生錯誤",
|
||||
"metadataRefreshStarted": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "媒體庫",
|
||||
@@ -634,7 +644,8 @@
|
||||
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
|
||||
"selectLibraries": "選取媒體庫",
|
||||
"none": "無"
|
||||
}
|
||||
},
|
||||
"onlyFavourites": "僅顯示收藏"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "播放佇列",
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/pl"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
@@ -387,3 +388,12 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
|
||||
func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) {
|
||||
s.broker.SendBroadcastMessage(ctx, status)
|
||||
}
|
||||
|
||||
// GetInstance returns the scanner singleton: Status reads the progress counters of the controller
|
||||
// running the scan, and scheduler, watcher and signal scans do not start from the API's injector.
|
||||
func GetInstance(rootCtx context.Context, ds model.DataStore, broker events.Broker,
|
||||
pls playlists.Playlists, m metrics.Metrics) model.Scanner {
|
||||
return singleton.GetInstance(func() *controller {
|
||||
return New(rootCtx, ds, broker, pls, m).(*controller)
|
||||
})
|
||||
}
|
||||
@@ -92,3 +92,13 @@ var _ = Describe("EffectiveFullScan", func() {
|
||||
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("GetInstance", func() {
|
||||
It("returns the same controller to every caller", func() {
|
||||
ds := &tests.MockDataStore{}
|
||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
a := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance())
|
||||
b := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance())
|
||||
Expect(a).To(BeIdenticalTo(b))
|
||||
})
|
||||
})
|
||||
@@ -96,6 +96,16 @@ func buildAuthPayload(user *model.User) map[string]any {
|
||||
return payload
|
||||
}
|
||||
|
||||
// MaxLoginBodySize bounds the payload of unauthenticated login routes across all APIs.
|
||||
const MaxLoginBodySize = 8 << 10
|
||||
|
||||
func LimitLoginBody(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, MaxLoginBodySize)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func getCredentialsFromBody(r *http.Request) (username string, password string, err error) {
|
||||
data := make(map[string]string)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
@@ -150,6 +160,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password
|
||||
err := ds.User(ctx).Put(&initialUser)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not create initial user", "user", initialUser, err)
|
||||
return fmt.Errorf("creating initial user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -64,6 +65,14 @@ var _ = Describe("Auth", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("createAdminUser", func() {
|
||||
It("returns the error when the user cannot be saved", func() {
|
||||
ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}}
|
||||
err := createAdminUser(context.Background(), ds, "johndoe", "secret")
|
||||
Expect(err).To(MatchError(ContainSubstring("db is down")))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Login from HTTP headers", func() {
|
||||
const (
|
||||
trustedIpv4 = "192.168.0.42"
|
||||
@@ -200,6 +209,13 @@ var _ = Describe("Auth", func() {
|
||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("rejects a request body larger than the limit", func() {
|
||||
body := `{"username":"janedoe", "password":"abc123", "padding":"` + strings.Repeat("x", MaxLoginBodySize) + `"}`
|
||||
req = httptest.NewRequest("POST", "/login", strings.NewReader(body))
|
||||
LimitLoginBody(http.HandlerFunc(login(ds))).ServeHTTP(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusUnprocessableEntity))
|
||||
})
|
||||
|
||||
It("logs in successfully if user exists", func() {
|
||||
usr := ds.User(context.Background())
|
||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/httprate"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -78,13 +77,12 @@ func (api *Router) routes() http.Handler {
|
||||
inner.Post("/system/ping", api.ping)
|
||||
inner.Get("/quickconnect/enabled", api.quickConnectEnabled)
|
||||
// Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated
|
||||
// brute-force surface, so it must share the same per-IP throttle when one is configured.
|
||||
// brute-force surface, so it must share the same per-client throttle when one is configured.
|
||||
login := inner.With(server.LimitLoginBody)
|
||||
if conf.Server.AuthRequestLimit > 0 {
|
||||
limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
|
||||
inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName)
|
||||
} else {
|
||||
inner.Post("/users/authenticatebyname", api.authenticateByName)
|
||||
login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
|
||||
}
|
||||
login.Post("/users/authenticatebyname", api.authenticateByName)
|
||||
inner.Get("/users/public", api.getPublicUsers)
|
||||
|
||||
// Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
@@ -84,4 +85,26 @@ var _ = Describe("Router", func() {
|
||||
Expect(login()).To(Equal(http.StatusUnauthorized))
|
||||
Expect(login()).To(Equal(http.StatusTooManyRequests))
|
||||
})
|
||||
|
||||
It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.AuthRequestLimit = 1
|
||||
conf.Server.AuthWindowLength = time.Minute
|
||||
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
// Every request arrives on the same proxy connection, so only the resolved client IP
|
||||
// can separate the buckets.
|
||||
handler := middleware.ClientIPFromHeader("X-Real-IP")(api)
|
||||
|
||||
login := func(clientIP string) int {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`))
|
||||
r.RemoteAddr = "10.0.0.1:1234"
|
||||
r.Header.Set("X-Real-IP", clientIP)
|
||||
handler.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
Expect(login("203.0.113.1")).To(Equal(http.StatusUnauthorized))
|
||||
Expect(login("203.0.113.1")).To(Equal(http.StatusTooManyRequests))
|
||||
Expect(login("203.0.113.2")).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -101,3 +102,15 @@ var _ = Describe("AuthenticateByName", func() {
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("AuthenticateByName body limit", func() {
|
||||
It("rejects a request body larger than the limit", func() {
|
||||
ds := &tests.MockDataStore{}
|
||||
api := &Router{ds: ds}
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"Username":"alice","Pw":"secret","Padding":"` + strings.Repeat("x", server.MaxLoginBodySize) + `"}`
|
||||
r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(body))
|
||||
server.LimitLoginBody(http.HandlerFunc(api.authenticateByName)).ServeHTTP(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
@@ -34,8 +34,8 @@ func imageSize(maxWidth, maxHeight int) int {
|
||||
}
|
||||
|
||||
func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
|
||||
// Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials
|
||||
// and item ids are unguessable, so resolution runs elevated to bypass the visibility filter.
|
||||
// Public, like Jellyfin's own image routes: clients build cover URLs without credentials, and
|
||||
// upstream resolves them with no visibility check either (LibraryManager.ItemIsVisible, null user).
|
||||
ctx := request.WithUser(r.Context(), model.User{IsAdmin: true})
|
||||
itemId, ok := itemIDParam(w, r, "itemId")
|
||||
if !ok {
|
||||
|
||||
@@ -136,7 +136,7 @@ func isSameMachine(r *http.Request, remote netip.Addr) bool {
|
||||
return parseIP(local.String()) == remote
|
||||
}
|
||||
|
||||
// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP.
|
||||
// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP.
|
||||
func remoteIP(r *http.Request) netip.Addr {
|
||||
return parseIP(r.RemoteAddr)
|
||||
}
|
||||
|
||||
+71
-11
@@ -7,7 +7,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -165,20 +168,77 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
|
||||
// context if navidrome is behind a trusted reverse proxy.
|
||||
// realIPMiddleware resolves the request's client IP into the context, where it can be read with
|
||||
// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration.
|
||||
// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that
|
||||
// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept
|
||||
// in the context as request.ReverseProxyIp.
|
||||
func realIPMiddleware(next http.Handler) http.Handler {
|
||||
if conf.Server.ExtAuth.TrustedSources != "" {
|
||||
return chi.Chain(
|
||||
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
|
||||
middleware.RealIP,
|
||||
).Handler(next)
|
||||
trusted := conf.Server.ExtAuth.TrustedSources
|
||||
fromPeer := middleware.ClientIPFromRemoteAddr(next)
|
||||
if trusted == "" {
|
||||
return fromPeer
|
||||
}
|
||||
|
||||
// The middleware is applied without a trusted reverse proxy to support other use-cases such as multiple clients
|
||||
// behind a caching proxy. In this case, navidrome only uses the request's RemoteAddr for logging, so the security
|
||||
// impact of reading the headers from untrusted sources is limited.
|
||||
return middleware.RealIP(next)
|
||||
// Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP,
|
||||
// X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list.
|
||||
fromProxy := chi.Chain(
|
||||
middleware.ClientIPFromRemoteAddr,
|
||||
middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...),
|
||||
middleware.ClientIPFromHeader("X-Real-IP"),
|
||||
middleware.ClientIPFromHeader("True-Client-IP"),
|
||||
).Handler(mirrorClientIP(next))
|
||||
|
||||
dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if validateIPAgainstList(r.RemoteAddr, trusted) {
|
||||
fromProxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr)
|
||||
fromPeer.ServeHTTP(w, r)
|
||||
})
|
||||
return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch)
|
||||
}
|
||||
|
||||
// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer.
|
||||
func mirrorClientIP(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) {
|
||||
r.RemoteAddr = ip
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// peerHost returns the host part of RemoteAddr, which may already be a bare IP.
|
||||
func peerHost(r *http.Request) string {
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR
|
||||
// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust
|
||||
// exactly one hop.
|
||||
func trustedProxyPrefixes(list string) []string {
|
||||
var prefixes []string
|
||||
for _, entry := range strings.Split(list, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if _, err := netip.ParsePrefix(entry); err == nil {
|
||||
prefixes = append(prefixes, entry)
|
||||
}
|
||||
}
|
||||
return prefixes
|
||||
}
|
||||
|
||||
// ClientIPRateLimiter returns a rate limiter keyed by the client IP resolved by realIPMiddleware,
|
||||
// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer
|
||||
// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket.
|
||||
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
|
||||
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
|
||||
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil
|
||||
})
|
||||
}
|
||||
|
||||
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -435,4 +436,100 @@ var _ = Describe("middlewares", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
Describe("realIPMiddleware", func() {
|
||||
var resolved, remoteAddr string
|
||||
var proxyIP any
|
||||
next := func(w http.ResponseWriter, r *http.Request) {
|
||||
resolved = middleware.GetClientIP(r.Context())
|
||||
remoteAddr = r.RemoteAddr
|
||||
proxyIP = r.Context().Value(request.ReverseProxyIp)
|
||||
}
|
||||
call := func(peer string, headers map[string]string) {
|
||||
resolved, remoteAddr, proxyIP = "", "", nil
|
||||
r := httptest.NewRequest("POST", "/auth/login", nil)
|
||||
r.RemoteAddr = peer
|
||||
for k, v := range headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
realIPMiddleware(http.HandlerFunc(next)).ServeHTTP(httptest.NewRecorder(), r)
|
||||
}
|
||||
|
||||
Context("without a trusted proxy", func() {
|
||||
It("ignores client-supplied forwarding headers", func() {
|
||||
call("10.0.0.1:1234", map[string]string{
|
||||
"X-Forwarded-For": "203.0.113.5",
|
||||
"X-Real-IP": "203.0.113.6",
|
||||
"True-Client-IP": "203.0.113.7",
|
||||
})
|
||||
Expect(resolved).To(Equal("10.0.0.1"))
|
||||
})
|
||||
It("leaves RemoteAddr untouched", func() {
|
||||
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
|
||||
Expect(remoteAddr).To(Equal("10.0.0.1:1234"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with a trusted proxy", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
|
||||
})
|
||||
It("uses the forwarded client IP when the peer is a trusted proxy", func() {
|
||||
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.1"})
|
||||
Expect(resolved).To(Equal("203.0.113.5"))
|
||||
Expect(remoteAddr).To(Equal("203.0.113.5"))
|
||||
})
|
||||
It("honours X-Real-IP from a trusted proxy", func() {
|
||||
call("10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.6"})
|
||||
Expect(resolved).To(Equal("203.0.113.6"))
|
||||
})
|
||||
It("ignores forwarding headers when the peer is not a trusted proxy", func() {
|
||||
call("198.51.100.9:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
|
||||
Expect(resolved).To(Equal("198.51.100.9"))
|
||||
Expect(remoteAddr).To(Equal("198.51.100.9:1234"))
|
||||
})
|
||||
It("keeps the peer address in the context for external auth", func() {
|
||||
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
|
||||
Expect(proxyIP).To(Equal("10.0.0.1:1234"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ClientIPRateLimiter", func() {
|
||||
var handler http.Handler
|
||||
JustBeforeEach(func() {
|
||||
handler = realIPMiddleware(ClientIPRateLimiter(2, time.Minute)(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })))
|
||||
})
|
||||
attempt := func(peer string, header, value string) int {
|
||||
r := httptest.NewRequest("POST", "/auth/login", nil)
|
||||
r.RemoteAddr = peer
|
||||
r.Header.Set(header, value)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
DescribeTable("keeps one bucket per peer when the forwarding header is rotated",
|
||||
func(header string) {
|
||||
Expect(attempt("198.51.100.9:1", header, "203.0.113.1")).To(Equal(http.StatusOK))
|
||||
Expect(attempt("198.51.100.9:2", header, "203.0.113.2")).To(Equal(http.StatusOK))
|
||||
Expect(attempt("198.51.100.9:3", header, "203.0.113.3")).To(Equal(http.StatusTooManyRequests))
|
||||
},
|
||||
Entry("X-Forwarded-For", "X-Forwarded-For"),
|
||||
Entry("X-Real-IP", "X-Real-IP"),
|
||||
Entry("True-Client-IP", "True-Client-IP"),
|
||||
)
|
||||
|
||||
Context("behind a trusted proxy", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
|
||||
})
|
||||
It("gives each real client its own bucket", func() {
|
||||
Expect(attempt("10.0.0.1:1", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK))
|
||||
Expect(attempt("10.0.0.1:2", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK))
|
||||
Expect(attempt("10.0.0.1:3", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusTooManyRequests))
|
||||
Expect(attempt("10.0.0.1:4", "X-Forwarded-For", "203.0.113.2")).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
@@ -205,11 +204,12 @@ func (s *Server) initRoutes() {
|
||||
func (s *Server) mountAuthenticationRoutes() chi.Router {
|
||||
r := s.router
|
||||
return r.Route(path.Join(conf.Server.BasePath, "/auth"), func(r chi.Router) {
|
||||
r.Use(LimitLoginBody)
|
||||
if conf.Server.AuthRequestLimit > 0 {
|
||||
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
|
||||
"windowLength", conf.Server.AuthWindowLength)
|
||||
|
||||
rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
|
||||
rateLimiter := ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
|
||||
r.With(rateLimiter).Post("/login", login(s.ds))
|
||||
} else {
|
||||
log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks")
|
||||
|
||||
@@ -205,3 +205,76 @@ var _ = Describe("Sharing Cross-User Isolation", Ordered, func() {
|
||||
Expect(check.Shares.Share[0].ID).To(Equal(shareID))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Sharing Downloadable Default", func() {
|
||||
var albumID string
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableSharing = true
|
||||
setupTestDB()
|
||||
conf.Server.EnableDownloads = true
|
||||
albumID = albumIDByName("Abbey Road")
|
||||
})
|
||||
|
||||
createShare := func(params ...string) *model.Share {
|
||||
GinkgoHelper()
|
||||
resp := doReq("createShare", append([]string{"id", albumID}, params...)...)
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Shares.Share).To(HaveLen(1))
|
||||
share, err := ds.Share(ctx).Get(resp.Shares.Share[0].ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return share
|
||||
}
|
||||
|
||||
DescribeTable("createShare resolves downloadable",
|
||||
func(defaultDownloadable, enableDownloads bool, params []string, expected bool) {
|
||||
conf.Server.DefaultDownloadableShare = defaultDownloadable
|
||||
conf.Server.EnableDownloads = enableDownloads
|
||||
|
||||
Expect(createShare(params...).Downloadable).To(Equal(expected))
|
||||
},
|
||||
Entry("applies the default when the param is absent", true, true, nil, true),
|
||||
Entry("stays off when the default is off", false, true, nil, false),
|
||||
Entry("ignores the default when downloads are disabled", true, false, nil, false),
|
||||
Entry("honors an explicit false over the default", true, true, []string{"downloadable", "false"}, false),
|
||||
Entry("honors an explicit true over the default", false, true, []string{"downloadable", "true"}, true),
|
||||
)
|
||||
|
||||
It("updateShare keeps the current downloadable when the param is absent", func() {
|
||||
conf.Server.DefaultDownloadableShare = true
|
||||
share := createShare()
|
||||
Expect(share.Downloadable).To(BeTrue())
|
||||
|
||||
resp := doReq("updateShare", "id", share.ID, "description", "Updated")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
updated, err := ds.Share(ctx).Get(share.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Description).To(Equal("Updated"))
|
||||
Expect(updated.Downloadable).To(BeTrue())
|
||||
})
|
||||
|
||||
It("updateShare applies an explicit downloadable and keeps the description", func() {
|
||||
conf.Server.DefaultDownloadableShare = true
|
||||
share := createShare("description", "Keep me")
|
||||
|
||||
resp := doReq("updateShare", "id", share.ID, "downloadable", "false")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
updated, err := ds.Share(ctx).Get(share.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Downloadable).To(BeFalse())
|
||||
Expect(updated.Description).To(Equal("Keep me"))
|
||||
})
|
||||
|
||||
It("updateShare clears the description when it is sent empty", func() {
|
||||
share := createShare("description", "Clear me")
|
||||
|
||||
resp := doReq("updateShare", "id", share.ID, "description", "")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
updated, err := ds.Share(ctx).Get(share.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Description).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,13 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
@@ -60,9 +62,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) {
|
||||
description, _ := p.String("description")
|
||||
repo := api.share.NewRepository(r.Context())
|
||||
share := &model.Share{
|
||||
Description: description,
|
||||
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
|
||||
ResourceIDs: strings.Join(ids, ","),
|
||||
Description: description,
|
||||
Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads),
|
||||
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
|
||||
ResourceIDs: strings.Join(ids, ","),
|
||||
}
|
||||
|
||||
id, err := repo.(rest.Persistable).Save(share)
|
||||
@@ -87,12 +90,27 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
description, _ := p.String("description")
|
||||
repo := api.share.NewRepository(r.Context())
|
||||
|
||||
// The update always writes description and downloadable, so read back the
|
||||
// stored value for whichever one the client omitted.
|
||||
description := p.StringPtr("description")
|
||||
downloadable := p.BoolPtr("downloadable")
|
||||
if description == nil || downloadable == nil {
|
||||
current, err := repo.Read(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cur := current.(*model.Share)
|
||||
description = cmp.Or(description, &cur.Description)
|
||||
downloadable = cmp.Or(downloadable, &cur.Downloadable)
|
||||
}
|
||||
|
||||
share := &model.Share{
|
||||
ID: id,
|
||||
Description: description,
|
||||
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
|
||||
ID: id,
|
||||
Description: *description,
|
||||
Downloadable: *downloadable,
|
||||
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
|
||||
}
|
||||
|
||||
err = repo.(rest.Persistable).Update(id, share)
|
||||
|
||||
@@ -280,12 +280,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
|
||||
return stream.IsAACCodec(p.Container)
|
||||
})
|
||||
|
||||
player, hasPlayer := request.PlayerFrom(ctx)
|
||||
|
||||
// Honor the player's forced transcoding format, falling back to normal
|
||||
// negotiation when the client can't play it (issue #5583).
|
||||
maxBitRate := 0
|
||||
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
|
||||
if !clientInfo.ForceFormat(trc.TargetFormat) {
|
||||
if clientInfo.ForceFormat(trc.TargetFormat) {
|
||||
// DirectPlayProfile carries no bitrate, so this ceiling is the only
|
||||
// thing keeping an over-bitrate source out of direct play.
|
||||
maxBitRate = trc.DefaultBitRate
|
||||
} else {
|
||||
clientName := clientInfo.Name
|
||||
if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" {
|
||||
if hasPlayer && player.Client != "" {
|
||||
clientName = player.Client
|
||||
}
|
||||
log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation",
|
||||
@@ -293,13 +300,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the player's MaxBitRate as a ceiling on the client's declared
|
||||
// limits (issue #5583). Both fields are capped because the client sends
|
||||
// them independently here; capping only MaxAudioBitrate would let an
|
||||
// independent MaxTranscodingAudioBitrate slip through computeBitrate.
|
||||
if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) {
|
||||
log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision",
|
||||
"playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
|
||||
// The player's own MaxBitRate outranks the forced-format default (issue #5583).
|
||||
if hasPlayer && player.MaxBitRate > 0 {
|
||||
maxBitRate = player.MaxBitRate
|
||||
}
|
||||
if clientInfo.CapBitrate(maxBitRate) {
|
||||
log.Debug(ctx, "Applied bitrate ceiling to transcode decision",
|
||||
"maxBitRate", maxBitRate, "client", clientInfo.Name)
|
||||
}
|
||||
|
||||
// Get media file
|
||||
|
||||
@@ -369,7 +369,7 @@ var _ = Describe("Transcode endpoints", func() {
|
||||
mockTD.token = "token"
|
||||
})
|
||||
|
||||
It("forces a supported format and clears direct play", func() {
|
||||
It("forces a supported format and narrows direct play to it", func() {
|
||||
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
|
||||
"transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"},
|
||||
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
||||
@@ -380,7 +380,11 @@ var _ = Describe("Transcode endpoints", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
||||
Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty())
|
||||
Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{
|
||||
Containers: []string{"ogg"},
|
||||
AudioCodecs: []string{"opus"},
|
||||
Protocols: []string{"http"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("falls back to negotiation when the forced format is unsupported", func() {
|
||||
@@ -416,6 +420,43 @@ var _ = Describe("Transcode endpoints", func() {
|
||||
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128))
|
||||
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128))
|
||||
})
|
||||
|
||||
withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request {
|
||||
ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate})
|
||||
ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate})
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
It("applies the transcoding default bitrate when the player sets no maxBitRate", func() {
|
||||
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
||||
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0)
|
||||
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
|
||||
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192))
|
||||
})
|
||||
|
||||
It("prefers the player maxBitRate over the transcoding default bitrate", func() {
|
||||
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
||||
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320)
|
||||
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
|
||||
})
|
||||
|
||||
It("ignores the transcoding default bitrate when the forced format is unsupported", func() {
|
||||
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
||||
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0)
|
||||
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -31,3 +31,9 @@ export const DEFAULT_SHARE_BITRATE = 128
|
||||
export const BITRATE_CHOICES = [
|
||||
32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320,
|
||||
].map((b) => ({ id: b, name: b.toString() }))
|
||||
|
||||
// 0 is a valid stored value ("no default bit rate") that BITRATE_CHOICES cannot express.
|
||||
export const TRANSCODING_BITRATE_CHOICES = [
|
||||
{ id: 0, name: 'resources.transcoding.choices.noDefaultBitRate' },
|
||||
...BITRATE_CHOICES,
|
||||
]
|
||||
@@ -200,6 +200,9 @@
|
||||
"targetFormat": "Target Format",
|
||||
"defaultBitRate": "Default Bit Rate",
|
||||
"command": "Command"
|
||||
},
|
||||
"choices": {
|
||||
"noDefaultBitRate": "None"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
useUnselectAll,
|
||||
} from 'react-admin'
|
||||
import { useSelector } from 'react-redux'
|
||||
import SyncIcon from '@material-ui/icons/Sync'
|
||||
import CachedIcon from '@material-ui/icons/Cached'
|
||||
import { GiMagnifyingGlass } from 'react-icons/gi'
|
||||
import { VscSync } from 'react-icons/vsc'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const LibraryScanButton = ({ fullScan, selectedIds, className }) => {
|
||||
@@ -54,7 +54,7 @@ const LibraryScanButton = ({ fullScan, selectedIds, className }) => {
|
||||
? translate('resources.library.actions.fullScan')
|
||||
: translate('resources.library.actions.quickScan')
|
||||
|
||||
const icon = fullScan ? <CachedIcon /> : <SyncIcon />
|
||||
const icon = fullScan ? <GiMagnifyingGlass /> : <VscSync />
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -598,11 +598,9 @@ const NautilineTheme = {
|
||||
},
|
||||
},
|
||||
NDAlbumGridView: {
|
||||
albumContainer: {
|
||||
link: {
|
||||
borderRadius: radii.md,
|
||||
'& img': {
|
||||
borderRadius: radii.md,
|
||||
},
|
||||
overflow: 'hidden',
|
||||
},
|
||||
albumTitle: {
|
||||
fontWeight: 600,
|
||||
|
||||
@@ -12,3 +12,25 @@ describe('NDPlaylistDetails styles', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('NDAlbumGridView styles', () => {
|
||||
const themeEntries = Object.entries(themes)
|
||||
|
||||
// The hover overlay is a sibling of the image, so it keeps square corners.
|
||||
it.each(themeEntries)(
|
||||
'%s should not round the grid cover image on its own',
|
||||
(themeName, theme) => {
|
||||
const container = theme.overrides?.NDAlbumGridView?.albumContainer
|
||||
expect(container?.['& img']?.borderRadius).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(themeEntries)(
|
||||
'%s should clip the grid cover link when it is rounded',
|
||||
(themeName, theme) => {
|
||||
const link = theme.overrides?.NDAlbumGridView?.link
|
||||
if (!link?.borderRadius) return
|
||||
expect(link.overflow).toBe('hidden')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -59,7 +59,11 @@ const useCurrentTheme = () => {
|
||||
return useMemo(
|
||||
() => ({
|
||||
...theme,
|
||||
props: { ...theme.props, MuiUseMediaQuery: { noSsr: true } },
|
||||
props: {
|
||||
...theme.props,
|
||||
MuiUseMediaQuery: { noSsr: true },
|
||||
MuiPopover: { disableScrollLock: true },
|
||||
},
|
||||
}),
|
||||
[theme],
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import { Title } from '../common'
|
||||
import { BITRATE_CHOICES } from '../consts'
|
||||
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
|
||||
|
||||
const TranscodingTitle = () => {
|
||||
const translate = useTranslate()
|
||||
@@ -28,7 +28,7 @@ const TranscodingCreate = (props) => (
|
||||
<TextInput source="targetFormat" validate={[required()]} />
|
||||
<SelectInput
|
||||
source="defaultBitRate"
|
||||
choices={BITRATE_CHOICES}
|
||||
choices={TRANSCODING_BITRATE_CHOICES}
|
||||
defaultValue={192}
|
||||
/>
|
||||
<TextInput
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'react-admin'
|
||||
import { Title } from '../common'
|
||||
import { TranscodingNote } from './TranscodingNote'
|
||||
import { BITRATE_CHOICES } from '../consts'
|
||||
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
|
||||
|
||||
const TranscodingTitle = ({ record }) => {
|
||||
const translate = useTranslate()
|
||||
@@ -28,7 +28,10 @@ const TranscodingEdit = (props) => {
|
||||
<SimpleForm variant={'outlined'}>
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<TextInput source="targetFormat" validate={[required()]} />
|
||||
<SelectInput source="defaultBitRate" choices={BITRATE_CHOICES} />
|
||||
<SelectInput
|
||||
source="defaultBitRate"
|
||||
choices={TRANSCODING_BITRATE_CHOICES}
|
||||
/>
|
||||
<TextInput source="command" fullWidth validate={[required()]} />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Datagrid, TextField } from 'react-admin'
|
||||
import { Datagrid, SelectField, TextField } from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { SimpleList, List } from '../common'
|
||||
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
|
||||
import config from '../config'
|
||||
|
||||
const TranscodingList = (props) => {
|
||||
@@ -16,13 +17,22 @@ const TranscodingList = (props) => {
|
||||
<SimpleList
|
||||
primaryText={(r) => r.name}
|
||||
secondaryText={(r) => `format: ${r.targetFormat}`}
|
||||
tertiaryText={(r) => r.defaultBitRate}
|
||||
tertiaryText={(r) => (
|
||||
<SelectField
|
||||
record={r}
|
||||
source="defaultBitRate"
|
||||
choices={TRANSCODING_BITRATE_CHOICES}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Datagrid rowClick={config.enableTranscodingConfig ? 'edit' : 'show'}>
|
||||
<TextField source="name" />
|
||||
<TextField source="targetFormat" />
|
||||
<TextField source="defaultBitRate" />
|
||||
<SelectField
|
||||
source="defaultBitRate"
|
||||
choices={TRANSCODING_BITRATE_CHOICES}
|
||||
/>
|
||||
<TextField source="command" />
|
||||
</Datagrid>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Show, SimpleShowLayout, TextField } from 'react-admin'
|
||||
import { SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'
|
||||
import { Title } from '../common'
|
||||
import { TranscodingNote } from './TranscodingNote'
|
||||
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
|
||||
|
||||
const TranscodingTitle = ({ record }) => {
|
||||
return <Title subTitle={`Transcoding ${record ? record.name : ''}`} />
|
||||
@@ -16,7 +17,10 @@ const TranscodingShow = (props) => {
|
||||
<SimpleShowLayout>
|
||||
<TextField source="name" />
|
||||
<TextField source="targetFormat" />
|
||||
<TextField source="defaultBitRate" />
|
||||
<SelectField
|
||||
source="defaultBitRate"
|
||||
choices={TRANSCODING_BITRATE_CHOICES}
|
||||
/>
|
||||
<TextField source="command" />
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
|
||||
Reference in new issue
Block a user