mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
179 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13af8ed43a | ||
|
|
825cbcbf53 | ||
|
|
5be73d404f | ||
|
|
1fa245d141 | ||
|
|
782cd26b3d | ||
|
|
10a1b5faf8 | ||
|
|
84dc10529d | ||
|
|
24d911744e | ||
|
|
6031d97c9d | ||
|
|
80acfc103f | ||
|
|
76614b8f16 | ||
|
|
d31952f469 | ||
|
|
32d2d7c15b | ||
|
|
669c8f4c49 | ||
|
|
3910e77a7a | ||
|
|
196557a41a | ||
|
|
11d96f1da4 | ||
|
|
e628aafa4b | ||
|
|
ecf934feab | ||
|
|
5b89bf747f | ||
|
|
7a6845fa5a | ||
|
|
b6433057e9 | ||
|
|
d0784b6a21 | ||
|
|
b0e7941abe | ||
|
|
a02cfbe2a7 | ||
|
|
04603a1ea2 | ||
|
|
50870d3e61 | ||
|
|
27780683aa | ||
|
|
5baf0b80aa | ||
|
|
46be041e7b | ||
|
|
ee2e04b832 | ||
|
|
1ba390a72a | ||
|
|
8134edb5d1 | ||
|
|
910a46120b | ||
|
|
8c86d0945c | ||
|
|
42047fde1a | ||
|
|
2887cd65fc | ||
|
|
8ac133027d | ||
|
|
f0240280eb | ||
|
|
d683688b0e | ||
|
|
180035c1e3 | ||
|
|
a132755d67 | ||
|
|
3107170afd | ||
|
|
d3bb4bb9a1 | ||
|
|
41f380451c | ||
|
|
e65eb225c8 | ||
|
|
e8d0f2ec2c | ||
|
|
47872c9e8a | ||
|
|
9ae2ec1a07 | ||
|
|
a1866c7ff3 | ||
|
|
9f1794b97e | ||
|
|
e1762882e3 | ||
|
|
870b217eb9 | ||
|
|
53af567b45 | ||
|
|
605aaf87d8 | ||
|
|
9950538089 | ||
|
|
4a55a148cf | ||
|
|
c1b75bca51 | ||
|
|
5baab4af77 | ||
|
|
4c87a39242 | ||
|
|
fc5d18feb7 | ||
|
|
4612b0a518 | ||
|
|
68ddbf4856 | ||
|
|
a6d72d8623 | ||
|
|
1a41525a7e | ||
|
|
8ca1aefad6 | ||
|
|
67d11dd144 | ||
|
|
9f65f8f5a8 | ||
|
|
bc06a59919 | ||
|
|
6709ab3c5e | ||
|
|
195f2b3f38 | ||
|
|
6ea688e720 | ||
|
|
496c95fd47 | ||
|
|
108bf31148 | ||
|
|
7c81143ca9 | ||
|
|
533c394f09 | ||
|
|
c95fa11a2f | ||
|
|
5d81849603 | ||
|
|
1a8bef0743 | ||
|
|
85bf7b5684 | ||
|
|
bdbff1ea38 | ||
|
|
5d58048780 | ||
|
|
723f01d98c | ||
|
|
c4bd0e67fa | ||
|
|
0c33523f45 | ||
|
|
14d085f651 | ||
|
|
4d4c71212f | ||
|
|
e1ba152a38 | ||
|
|
eaa7f7c7e9 | ||
|
|
290333ec59 | ||
|
|
fa85e2a781 | ||
|
|
5360283bb0 | ||
|
|
e59d81bf78 | ||
|
|
7b2ddfd65a | ||
|
|
76c3f5131a | ||
|
|
f577704d7a | ||
|
|
f46ff73c53 | ||
|
|
d046c180bf | ||
|
|
9b4abd9e5a | ||
|
|
0de5f594fe | ||
|
|
33717f26d4 | ||
|
|
6722395879 | ||
|
|
2667ad3921 | ||
|
|
3e1fa20413 | ||
|
|
1802015737 | ||
|
|
47378c6882 | ||
|
|
81459cc421 | ||
|
|
4cda3a58dc | ||
|
|
56557bb0f3 | ||
|
|
c60f443179 | ||
|
|
fa3998d6e1 | ||
|
|
8542ac96c0 | ||
|
|
4557add7ef | ||
|
|
004fae43f5 | ||
|
|
7111535963 | ||
|
|
3bc9e75b28 | ||
|
|
3993c4d17f | ||
|
|
29b7b740ce | ||
|
|
29bc17acd7 | ||
|
|
4044642abf | ||
|
|
88eac6d7f3 | ||
|
|
f267f55713 | ||
|
|
58990c4830 | ||
|
|
7a20233a35 | ||
|
|
45679e11c2 | ||
|
|
05f34b0cce | ||
|
|
586e725d6c | ||
|
|
a7c4c72dc6 | ||
|
|
232c45bd06 | ||
|
|
1b77830eb4 | ||
|
|
e535f7eb78 | ||
|
|
d8b2f3d2cf | ||
|
|
56303cde23 | ||
|
|
e434ca9372 | ||
|
|
3252fab171 | ||
|
|
6d526870b7 | ||
|
|
34678611c0 | ||
|
|
0f7d6b5bc4 | ||
|
|
939f3eee97 | ||
|
|
b4ef1b1e38 | ||
|
|
11bef060a3 | ||
|
|
abe5690018 | ||
|
|
46fc38bf61 | ||
|
|
6d8d519807 | ||
|
|
da9cf22b6b | ||
|
|
8c3919d6a0 | ||
|
|
4df69bd334 | ||
|
|
ee73a9d297 | ||
|
|
0488fb92cb | ||
|
|
61903facdf | ||
|
|
b6fce0e686 | ||
|
|
f88d3f82da | ||
|
|
55bff343cd | ||
|
|
68f03d0167 | ||
|
|
643c763cb4 | ||
|
|
67865512c8 | ||
|
|
b2ecc1d16f | ||
|
|
bcaa180fc7 | ||
|
|
aeed5a7099 | ||
|
|
3977ef6e0f | ||
|
|
653b4d97f9 | ||
|
|
98218d045e | ||
|
|
a9feeac793 | ||
|
|
1c0551f4f7 | ||
|
|
15c9a0ded3 | ||
|
|
5d41165b5b | ||
|
|
0a763b91d5 | ||
|
|
4d28d534cc | ||
|
|
a7a4fb522c | ||
|
|
7f52ff72dc | ||
|
|
8ed07333ed | ||
|
|
52235c291d | ||
|
|
de0a08915c | ||
|
|
45c4583f1b | ||
|
|
478c709a64 | ||
|
|
477bcaee58 | ||
|
|
081ef85db6 | ||
|
|
6f2643e55e | ||
|
|
9ee63b39cb |
@@ -4,7 +4,7 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.22",
|
||||
"VARIANT": "1.23",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v20"
|
||||
@@ -18,33 +18,37 @@
|
||||
"--volume=${localWorkspaceFolder}:/workspaces/${localWorkspaceFolderBasename}:Z"
|
||||
],
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"go.useGoProxyToCheckForToolUpdates": false,
|
||||
"go.useLanguageServer": true,
|
||||
"go.gopath": "/go",
|
||||
"go.goroot": "/usr/local/go",
|
||||
"go.toolsGopath": "/go/bin",
|
||||
"go.formatTool": "goimports",
|
||||
"go.lintOnSave": "package",
|
||||
"go.lintTool": "golangci-lint",
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"go.useGoProxyToCheckForToolUpdates": false,
|
||||
"go.useLanguageServer": true,
|
||||
"go.gopath": "/go",
|
||||
"go.goroot": "/usr/local/go",
|
||||
"go.toolsGopath": "/go/bin",
|
||||
"go.formatTool": "goimports",
|
||||
"go.lintOnSave": "package",
|
||||
"go.lintTool": "golangci-lint",
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"golang.Go",
|
||||
"esbenp.prettier-vscode",
|
||||
"tamasfe.even-better-toml"
|
||||
]
|
||||
}
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"golang.Go",
|
||||
"esbenp.prettier-vscode",
|
||||
"tamasfe.even-better-toml"
|
||||
],
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
4533,
|
||||
|
||||
26
.github/workflows/pipeline.yml
vendored
26
.github/workflows/pipeline.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
go-lint:
|
||||
name: Lint Go code
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.22.3-1
|
||||
container: deluan/ci-goreleaser:1.23.0-1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -24,7 +24,6 @@ jobs:
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
problem-matchers: true
|
||||
args: --timeout 2m
|
||||
|
||||
@@ -44,7 +43,7 @@ jobs:
|
||||
go:
|
||||
name: Test Go code
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.22.3-1
|
||||
container: deluan/ci-goreleaser:1.23.0-1
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v4
|
||||
@@ -100,11 +99,28 @@ jobs:
|
||||
path: ui/build
|
||||
retention-days: 7
|
||||
|
||||
i18n-lint:
|
||||
name: Lint i18n files
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
set -e
|
||||
for file in resources/i18n/*.json; do
|
||||
echo "Validating $file"
|
||||
if ! jq empty "$file" 2>error.log; then
|
||||
error_message=$(cat error.log)
|
||||
line_number=$(echo "$error_message" | grep -oP 'line \K[0-9]+')
|
||||
echo "::error file=$file,line=$line_number::$error_message"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
binaries:
|
||||
name: Build binaries
|
||||
needs: [js, go, go-lint]
|
||||
needs: [js, go, go-lint, i18n-lint]
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.22.3-1
|
||||
container: deluan/ci-goreleaser:1.23.0-1
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
44
.github/workflows/update-translations.sh
vendored
Executable file
44
.github/workflows/update-translations.sh
vendored
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
I18N_DIR=resources/i18n
|
||||
|
||||
# Function to process JSON: remove empty attributes and sort
|
||||
process_json() {
|
||||
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
|
||||
}
|
||||
|
||||
check_lang_diff() {
|
||||
filename=${I18N_DIR}/"$1".json
|
||||
url=$(curl -s -X POST https://poeditor.com/api/ \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d action="export" \
|
||||
-d id="${POEDITOR_PROJECTID}" \
|
||||
-d language="$1" \
|
||||
-d type="key_value_json" | jq -r .item)
|
||||
if [ -z "$url" ]; then
|
||||
echo "Failed to export $1"
|
||||
return 1
|
||||
fi
|
||||
curl -sSL "$url" > poeditor.json
|
||||
|
||||
process_json "$filename" > "$filename".tmp
|
||||
process_json poeditor.json > poeditor.tmp
|
||||
|
||||
diff=$(diff -u "$filename".tmp poeditor.tmp) || true
|
||||
if [ -n "$diff" ]; then
|
||||
echo "$diff"
|
||||
mv poeditor.json "$filename"
|
||||
fi
|
||||
|
||||
rm -f poeditor.json poeditor.tmp "$filename".tmp
|
||||
}
|
||||
|
||||
for file in ${I18N_DIR}/*.json; do
|
||||
name=$(basename "$file")
|
||||
code=$(echo "$name" | cut -f1 -d.)
|
||||
lang=$(jq -r .languageName < "$file")
|
||||
echo "Downloading $lang ($code)"
|
||||
check_lang_diff "$code"
|
||||
done
|
||||
4
.github/workflows/update-translations.yml
vendored
4
.github/workflows/update-translations.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
||||
run: |
|
||||
./update-translations.sh
|
||||
.github/workflows/update-translations.sh
|
||||
- name: Show changes, if any
|
||||
run: |
|
||||
git status --porcelain
|
||||
@@ -24,5 +24,5 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
commit-message: Update translations
|
||||
title: Update translations from POEditor
|
||||
title: "fix(ui): update translations from POEditor"
|
||||
branch: update-translations
|
||||
|
||||
@@ -4,11 +4,11 @@ linters:
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- copyloopvar
|
||||
- dogsled
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errorlint
|
||||
- exportloopref
|
||||
- gocyclo
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
@@ -26,8 +26,12 @@ linters:
|
||||
- whitespace
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
gosec:
|
||||
excludes:
|
||||
- G501
|
||||
- G401
|
||||
- G505
|
||||
- G115 # Can't check context, where the warning is clearly a false positive. See discussion in https://github.com/securego/gosec/pull/1149
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# GoReleaser config
|
||||
project_name: navidrome
|
||||
version: 2
|
||||
|
||||
builds:
|
||||
- id: navidrome_linux_amd64
|
||||
@@ -121,13 +122,51 @@ checksum:
|
||||
name_template: "{{ .ProjectName }}_checksums.txt"
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-SNAPSHOT"
|
||||
version_template: "{{ .Tag }}-SNAPSHOT"
|
||||
|
||||
release:
|
||||
draft: true
|
||||
mode: append
|
||||
footer: |
|
||||
**Full Changelog**: https://github.com/navidrome/navidrome/compare/{{ .PreviousTag }}...{{ .Tag }}
|
||||
|
||||
## Helping out
|
||||
|
||||
This release is only possible thanks to the support of some **awesome people**!
|
||||
|
||||
Want to be one of them?
|
||||
You can [sponsor](https://github.com/sponsors/deluan), pay me a [Ko-fi](https://ko-fi.com/deluan) or [contribute with code](https://www.navidrome.org/docs/developers/).
|
||||
|
||||
## Where to go next?
|
||||
|
||||
* Read installation instructions on our [website](https://www.navidrome.org/docs/installation/).
|
||||
* Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)!
|
||||
|
||||
changelog:
|
||||
# sort: asc
|
||||
sort: asc
|
||||
use: github
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- Merge pull request
|
||||
- Merge remote-tracking branch
|
||||
- Merge branch
|
||||
- go mod tidy
|
||||
groups:
|
||||
- title: "New Features"
|
||||
regexp: '^.*?feat(\(.+\))??!?:.+$'
|
||||
order: 100
|
||||
- title: "Security updates"
|
||||
regexp: '^.*?sec(\(.+\))??!?:.+$'
|
||||
order: 150
|
||||
- title: "Bug fixes"
|
||||
regexp: '^.*?(fix|refactor)(\(.+\))??!?:.+$'
|
||||
order: 200
|
||||
- title: "Documentation updates"
|
||||
regexp: ^.*?docs?(\(.+\))??!?:.+$
|
||||
order: 400
|
||||
- title: "Build process updates"
|
||||
regexp: ^.*?(build|ci)(\(.+\))??!?:.+$
|
||||
order: 400
|
||||
- title: Other work
|
||||
order: 9999
|
||||
|
||||
@@ -48,14 +48,15 @@ This improves the readability of the messages
|
||||
It can be one of the following:
|
||||
1. **feat**: Addition of a new feature
|
||||
2. **fix**: Bug fix
|
||||
3. **docs**: Documentation Changes
|
||||
4. **style**: Changes to styling
|
||||
5. **refactor**: Refactoring of code
|
||||
6. **perf**: Code that affects performance
|
||||
7. **test**: Updating or improving the current tests
|
||||
8. **build**: Changes to Build process
|
||||
9. **revert**: Reverting to a previous commit
|
||||
10. **chore** : updating grunt tasks etc
|
||||
3. **sec**: Fixing security issues
|
||||
4. **docs**: Documentation Changes
|
||||
5. **style**: Changes to styling
|
||||
6. **refactor**: Refactoring of code
|
||||
7. **perf**: Code that affects performance
|
||||
8. **test**: Updating or improving the current tests
|
||||
9. **build**: Changes to Build process
|
||||
10. **revert**: Reverting to a previous commit
|
||||
11. **chore** : updating grunt tasks etc
|
||||
|
||||
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section
|
||||
|
||||
|
||||
34
Makefile
34
Makefile
@@ -9,7 +9,9 @@ GIT_SHA=source_archive
|
||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
|
||||
endif
|
||||
|
||||
CI_RELEASER_VERSION=1.22.3-1 ## https://github.com/navidrome/ci-goreleaser
|
||||
CI_RELEASER_VERSION ?= 1.23.0-1 ## https://github.com/navidrome/ci-goreleaser
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
@echo Downloading Node dependencies...
|
||||
@@ -20,7 +22,7 @@ dev: check_env ##@Development Start Navidrome in development mode, with hot-re
|
||||
npx foreman -j Procfile.dev -p 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
server: check_go_env ##@Development Start the backend in development mode
|
||||
server: check_go_env buildjs ##@Development Start the backend in development mode
|
||||
@go run github.com/cespare/reflex@latest -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
@@ -78,28 +80,30 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
||||
@(cd .git/hooks && ln -sf ../../git/* .)
|
||||
.PHONY: setup-git
|
||||
|
||||
buildall: buildjs build ##@Build Build the project, both frontend and backend
|
||||
.PHONY: buildall
|
||||
|
||||
build: warning-noui-build check_go_env ##@Build Build only backend
|
||||
build: check_go_env buildjs ##@Build Build the project
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
|
||||
.PHONY: build
|
||||
|
||||
debug-build: warning-noui-build check_go_env ##@Build Build only backend (with remote debug on)
|
||||
buildall: deprecated build
|
||||
.PHONY: buildall
|
||||
|
||||
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
|
||||
.PHONY: debug-build
|
||||
|
||||
buildjs: check_node_env ##@Build Build only frontend
|
||||
@(cd ./ui && npm run build)
|
||||
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
||||
.PHONY: buildjs
|
||||
|
||||
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
|
||||
ui/build/index.html: $(UI_SRC_FILES)
|
||||
@(cd ./ui && npm run build)
|
||||
|
||||
all: buildjs ##@Cross_Compilation Build binaries for all supported platforms.
|
||||
@echo "Building binaries for all platforms using builder ${CI_RELEASER_VERSION}"
|
||||
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||
goreleaser release --clean --skip=publish --snapshot
|
||||
.PHONY: all
|
||||
|
||||
single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
|
||||
single: buildjs ##@Cross_Compilation Build binaries for a single supported platforms.
|
||||
@if [ -z "${GOOS}" -o -z "${GOARCH}" ]; then \
|
||||
echo "Usage: GOOS=<os> GOARCH=<arch> make single"; \
|
||||
echo "Options:"; \
|
||||
@@ -117,10 +121,6 @@ docker: buildjs ##@Build Build Docker linux/amd64 image (tagged as `deluan/navid
|
||||
docker build . --platform linux/amd64 -t deluan/navidrome:develop -f .github/workflows/pipeline.dockerfile
|
||||
.PHONY: docker
|
||||
|
||||
warning-noui-build:
|
||||
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
|
||||
.PHONY: warning-noui-build
|
||||
|
||||
get-music: ##@Development Download some free music from Navidrome's demo instance
|
||||
mkdir -p music
|
||||
( cd music; \
|
||||
@@ -175,6 +175,10 @@ check_node_env:
|
||||
pre-push: lintall testall
|
||||
.PHONY: pre-push
|
||||
|
||||
deprecated:
|
||||
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
||||
.PHONY: deprecated
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
HELP_FUN = \
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](CODE_OF_CONDUCT.md)
|
||||
|
||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||
|
||||
@@ -119,7 +119,7 @@ func startServer(ctx context.Context) func() error {
|
||||
a.MountRouter("Profiling", "/debug", middleware.Profiler())
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler())
|
||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||
}
|
||||
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
|
||||
}
|
||||
|
||||
@@ -29,16 +29,16 @@ import (
|
||||
// Injectors from wire_injectors.go:
|
||||
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
broker := events.GetBroker()
|
||||
serverServer := server.New(dataStore, broker)
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists)
|
||||
@@ -46,8 +46,8 @@ func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
}
|
||||
|
||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.New(dataStore)
|
||||
@@ -69,8 +69,8 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.New(dataStore)
|
||||
@@ -85,22 +85,22 @@ func CreatePublicRouter() *public.Router {
|
||||
}
|
||||
|
||||
func CreateLastFMRouter() *lastfm.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
router := lastfm.NewRouter(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
router := listenbrainz.NewRouter(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
@@ -114,8 +114,8 @@ func GetScanner() scanner.Scanner {
|
||||
}
|
||||
|
||||
func GetPlaybackServer() playback.PlaybackServer {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
@@ -17,73 +17,76 @@ import (
|
||||
)
|
||||
|
||||
type configOptions struct {
|
||||
ConfigFile string
|
||||
Address string
|
||||
Port int
|
||||
UnixSocketPerm string
|
||||
MusicFolder string
|
||||
DataFolder string
|
||||
CacheFolder string
|
||||
DbPath string
|
||||
LogLevel string
|
||||
ScanInterval time.Duration
|
||||
ScanSchedule string
|
||||
SessionTimeout time.Duration
|
||||
BaseURL string
|
||||
BasePath string
|
||||
BaseHost string
|
||||
BaseScheme string
|
||||
TLSCert string
|
||||
TLSKey string
|
||||
UILoginBackgroundURL string
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
AlbumPlayCountMode string
|
||||
EnableArtworkPrecache bool
|
||||
AutoImportPlaylists bool
|
||||
PlaylistsPath string
|
||||
AutoTranscodeDownload bool
|
||||
DefaultDownsamplingFormat string
|
||||
SearchFullString bool
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
SubsonicArtistParticipations bool
|
||||
FFmpegPath string
|
||||
MPVPath string
|
||||
MPVCmdTemplate string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
ArtistArtPriority string
|
||||
EnableGravatar bool
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
GATrackingID string
|
||||
EnableLogRedacting bool
|
||||
AuthRequestLimit int
|
||||
AuthWindowLength time.Duration
|
||||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
HTTPSecurityHeaders secureOptions
|
||||
Prometheus prometheusOptions
|
||||
Scanner scannerOptions
|
||||
Jukebox jukeboxOptions
|
||||
ConfigFile string
|
||||
Address string
|
||||
Port int
|
||||
UnixSocketPerm string
|
||||
MusicFolder string
|
||||
DataFolder string
|
||||
CacheFolder string
|
||||
DbPath string
|
||||
LogLevel string
|
||||
ScanInterval time.Duration
|
||||
ScanSchedule string
|
||||
SessionTimeout time.Duration
|
||||
BaseURL string
|
||||
BasePath string
|
||||
BaseHost string
|
||||
BaseScheme string
|
||||
TLSCert string
|
||||
TLSKey string
|
||||
UILoginBackgroundURL string
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
AlbumPlayCountMode string
|
||||
EnableArtworkPrecache bool
|
||||
AutoImportPlaylists bool
|
||||
DefaultPlaylistPublicVisibility bool
|
||||
PlaylistsPath string
|
||||
SmartPlaylistRefreshDelay time.Duration
|
||||
AutoTranscodeDownload bool
|
||||
DefaultDownsamplingFormat string
|
||||
SearchFullString bool
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
SubsonicArtistParticipations bool
|
||||
FFmpegPath string
|
||||
MPVPath string
|
||||
MPVCmdTemplate string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
ArtistArtPriority string
|
||||
EnableGravatar bool
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
GATrackingID string
|
||||
EnableLogRedacting bool
|
||||
AuthRequestLimit int
|
||||
AuthWindowLength time.Duration
|
||||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
HTTPSecurityHeaders secureOptions
|
||||
Prometheus prometheusOptions
|
||||
Scanner scannerOptions
|
||||
Jukebox jukeboxOptions
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
@@ -299,7 +302,9 @@ func init() {
|
||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||
viper.SetDefault("enableartworkprecache", true)
|
||||
viper.SetDefault("autoimportplaylists", true)
|
||||
viper.SetDefault("defaultplaylistpublicvisibility", false)
|
||||
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
||||
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
viper.SetDefault("enableexternalservices", true)
|
||||
viper.SetDefault("enablemediafilecoverart", true)
|
||||
@@ -366,6 +371,7 @@ func init() {
|
||||
viper.SetDefault("devautologinusername", "")
|
||||
viper.SetDefault("devactivitypanel", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
const (
|
||||
AppName = "navidrome"
|
||||
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_cache_size=1000000000&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=on&_txlock=immediate"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
|
||||
@@ -60,7 +60,7 @@ var _ = Describe("Agents", func() {
|
||||
Describe("GetArtistMBID", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
|
||||
@@ -78,7 +78,7 @@ var _ = Describe("Agents", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistMBID(ctx, "123", "test")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -91,7 +91,7 @@ var _ = Describe("Agents", func() {
|
||||
Describe("GetArtistURL", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistURL(ctx, "123", "test", "mb123")).To(Equal("url"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
@@ -109,7 +109,7 @@ var _ = Describe("Agents", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -122,7 +122,7 @@ var _ = Describe("Agents", func() {
|
||||
Describe("GetArtistBiography", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistBiography(ctx, "123", "test", "mb123")).To(Equal("bio"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
@@ -140,7 +140,7 @@ var _ = Describe("Agents", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -156,13 +156,13 @@ var _ = Describe("Agents", func() {
|
||||
URL: "imageUrl",
|
||||
Size: 100,
|
||||
}}))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError("not found"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -178,13 +178,13 @@ var _ = Describe("Agents", func() {
|
||||
Name: "Joe Dohn",
|
||||
MBID: "mbid321",
|
||||
}}))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -200,13 +200,13 @@ var _ = Describe("Agents", func() {
|
||||
Name: "A Song",
|
||||
MBID: "mbid444",
|
||||
}}))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -236,13 +236,13 @@ var _ = Describe("Agents", func() {
|
||||
},
|
||||
},
|
||||
}))
|
||||
Expect(mock.Args).To(ConsistOf("album", "artist", "mbid"))
|
||||
Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetAlbumInfo(ctx, "album", "artist", "mbid")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(ConsistOf("album", "artist", "mbid"))
|
||||
Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
|
||||
@@ -20,8 +20,8 @@ import (
|
||||
var ErrUnavailable = errors.New("artwork unavailable")
|
||||
|
||||
type Artwork interface {
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int) (io.ReadCloser, time.Time, error)
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int) (io.ReadCloser, time.Time, error)
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
}
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork {
|
||||
@@ -41,10 +41,10 @@ type artworkReader interface {
|
||||
Reader(ctx context.Context) (io.ReadCloser, string, error)
|
||||
}
|
||||
|
||||
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artID, err := a.getArtworkId(ctx, id)
|
||||
if err == nil {
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size)
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
|
||||
}
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
if artID.Kind == model.KindArtistArtwork {
|
||||
@@ -57,8 +57,8 @@ func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (re
|
||||
return reader, lastUpdate, err
|
||||
}
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artReader, err := a.getArtworkReader(ctx, artID, size)
|
||||
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artReader, err := a.getArtworkReader(ctx, artID, size, square)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
@@ -107,11 +107,11 @@ func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID,
|
||||
return artID, nil
|
||||
}
|
||||
|
||||
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int) (artworkReader, error) {
|
||||
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
|
||||
var artReader artworkReader
|
||||
var err error
|
||||
if size > 0 {
|
||||
artReader, err = resizedFromOriginal(ctx, a, artID, size)
|
||||
if size > 0 || square {
|
||||
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
|
||||
} else {
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
|
||||
@@ -4,7 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -211,33 +215,83 @@ var _ = Describe("Artwork", func() {
|
||||
alMultipleCovers,
|
||||
})
|
||||
})
|
||||
It("returns a PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
When("Square is false", func() {
|
||||
It("returns a PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
br, format, err := asImageReader(r)
|
||||
Expect(format).To(Equal("image/png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns a JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, _, err := image.Decode(br)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
It("returns a JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
When("When square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
br, format, err := asImageReader(r)
|
||||
Expect(format).To(Equal("image/jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DescribeTable("resize",
|
||||
func(format string, landscape bool, size int) {
|
||||
coverFileName := "cover." + format
|
||||
dirName := createImage(format, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
ImageFiles: filepath.Join(dirName, coverFileName),
|
||||
}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
|
||||
img, _, err := image.Decode(br)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
conf.Server.CoverArtPriority = coverFileName
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(size))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||
},
|
||||
Entry("portrait png image", "png", false, 200),
|
||||
Entry("landscape png image", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", false, 200),
|
||||
Entry("landscape jpg image", "jpg", true, 200),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func createImage(format string, landscape bool, size int) string {
|
||||
var img image.Image
|
||||
|
||||
if landscape {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
} else {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
}
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
_ = png.Encode(f, img)
|
||||
case "jpg":
|
||||
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ var _ = Describe("Artwork", func() {
|
||||
Context("GetOrPlaceholder", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns placeholder if album is not in the DB", func() {
|
||||
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0)
|
||||
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
@@ -49,7 +49,7 @@ var _ = Describe("Artwork", func() {
|
||||
Context("Get", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns an ErrUnavailable error", func() {
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0)
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -14,7 +16,6 @@ import (
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/pl"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
type CacheWarmer interface {
|
||||
@@ -94,7 +95,7 @@ func (a *cacheWarmer) run(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
batch := maps.Keys(a.buffer)
|
||||
batch := slices.Collect(maps.Keys(a.buffer))
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
|
||||
@@ -121,7 +122,7 @@ func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID)
|
||||
input := pl.FromSlice(ctx, batch)
|
||||
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
|
||||
for err := range errs {
|
||||
log.Warn(ctx, "Error warming cache", err)
|
||||
log.Debug(ctx, "Error warming cache", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,9 +130,9 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize)
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error caching id='%s': %w", id, err)
|
||||
return fmt.Errorf("caching id='%s': %w", id, err)
|
||||
}
|
||||
defer r.Close()
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type artistReader struct {
|
||||
@@ -56,7 +56,7 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
|
||||
}
|
||||
}
|
||||
a.files = strings.Join(files, consts.Zwsp)
|
||||
a.artistFolder = utils.LongestCommonPrefix(paths)
|
||||
a.artistFolder = str.LongestCommonPrefix(paths)
|
||||
if !strings.HasSuffix(a.artistFolder, string(filepath.Separator)) {
|
||||
a.artistFolder, _ = filepath.Split(a.artistFolder)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
@@ -23,16 +21,18 @@ type resizedArtworkReader struct {
|
||||
cacheKey string
|
||||
lastUpdate time.Time
|
||||
size int
|
||||
square bool
|
||||
a *artwork
|
||||
}
|
||||
|
||||
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int) (*resizedArtworkReader, error) {
|
||||
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) {
|
||||
r := &resizedArtworkReader{a: a}
|
||||
r.artID = artID
|
||||
r.size = size
|
||||
r.square = square
|
||||
|
||||
// Get lastUpdated and cacheKey from original artwork
|
||||
original, err := a.getArtworkReader(ctx, artID, 0)
|
||||
original, err := a.getArtworkReader(ctx, artID, 0, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -42,12 +42,11 @@ func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID,
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) Key() string {
|
||||
return fmt.Sprintf(
|
||||
"%s.%d.%d",
|
||||
a.cacheKey,
|
||||
a.size,
|
||||
conf.Server.CoverJpegQuality,
|
||||
)
|
||||
baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size)
|
||||
if a.square {
|
||||
return baseKey + ".square"
|
||||
}
|
||||
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality)
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) LastUpdated() time.Time {
|
||||
@@ -56,7 +55,7 @@ func (a *resizedArtworkReader) LastUpdated() time.Time {
|
||||
|
||||
func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
// Get artwork in original size, possibly from cache
|
||||
orig, _, err := a.a.Get(ctx, a.artID, 0)
|
||||
orig, _, err := a.a.Get(ctx, a.artID, 0, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -66,7 +65,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||
r := io.TeeReader(orig, buf)
|
||||
defer orig.Close()
|
||||
|
||||
resized, origSize, err := resizeImage(r, a.size)
|
||||
resized, origSize, err := resizeImage(r, a.size, a.square)
|
||||
if resized == nil {
|
||||
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size)
|
||||
} else {
|
||||
@@ -83,54 +82,39 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||
}
|
||||
|
||||
func asImageReader(r io.Reader) (io.Reader, string, error) {
|
||||
br := bufio.NewReader(r)
|
||||
buf, err := br.Peek(512)
|
||||
if err == io.EOF && len(buf) > 0 {
|
||||
// Check if there are enough bytes to detect type
|
||||
typ := http.DetectContentType(buf)
|
||||
if typ != "" {
|
||||
return br, typ, nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return br, http.DetectContentType(buf), nil
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
|
||||
r, format, err := asImageReader(reader)
|
||||
func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Don't upscale the image
|
||||
bounds := img.Bounds()
|
||||
bounds := original.Bounds()
|
||||
originalSize := max(bounds.Max.X, bounds.Max.Y)
|
||||
if originalSize <= size {
|
||||
|
||||
if originalSize <= size && !square {
|
||||
return nil, originalSize, nil
|
||||
}
|
||||
|
||||
var m *image.NRGBA
|
||||
// Preserve the aspect ratio of the image.
|
||||
if bounds.Max.X > bounds.Max.Y {
|
||||
m = imaging.Resize(img, size, 0, imaging.Lanczos)
|
||||
var resized image.Image
|
||||
if originalSize >= size {
|
||||
resized = imaging.Fit(original, size, size, imaging.Lanczos)
|
||||
} else {
|
||||
m = imaging.Resize(img, 0, size, imaging.Lanczos)
|
||||
if bounds.Max.Y < bounds.Max.X {
|
||||
resized = imaging.Resize(original, size, 0, imaging.Lanczos)
|
||||
} else {
|
||||
resized = imaging.Resize(original, 0, size, imaging.Lanczos)
|
||||
}
|
||||
}
|
||||
if square {
|
||||
bg := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
resized = imaging.OverlayCenter(bg, resized, 1)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
buf.Reset()
|
||||
if format == "image/png" {
|
||||
err = png.Encode(buf, m)
|
||||
if format == "png" || square {
|
||||
err = png.Encode(buf, resized)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
}
|
||||
return buf, originalSize, err
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs
|
||||
}
|
||||
log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("could not get a cover art for %s: %w", artID, ErrUnavailable)
|
||||
return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
|
||||
}
|
||||
|
||||
type sourceFunc func() (r io.ReadCloser, path string, err error)
|
||||
@@ -124,7 +124,7 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
|
||||
|
||||
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _, err := a.Get(ctx, id, 0)
|
||||
r, _, err := a.Get(ctx, id, 0, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/random"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
@@ -42,8 +43,8 @@ type ExternalMetadata interface {
|
||||
type externalMetadata struct {
|
||||
ds model.DataStore
|
||||
ag *agents.Agents
|
||||
artistQueue chan<- *auxArtist
|
||||
albumQueue chan<- *auxAlbum
|
||||
artistQueue refreshQueue[auxArtist]
|
||||
albumQueue refreshQueue[auxAlbum]
|
||||
}
|
||||
|
||||
type auxAlbum struct {
|
||||
@@ -58,8 +59,8 @@ type auxArtist struct {
|
||||
|
||||
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
|
||||
e := &externalMetadata{ds: ds, ag: agents}
|
||||
e.artistQueue = startRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = startRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@ func (e *externalMetadata) getAlbum(ctx context.Context, id string) (*auxAlbum,
|
||||
switch v := entity.(type) {
|
||||
case *model.Album:
|
||||
album.Album = *v
|
||||
album.Name = clearName(v.Name)
|
||||
album.Name = str.Clear(v.Name)
|
||||
case *model.MediaFile:
|
||||
return e.getAlbum(ctx, v.AlbumID)
|
||||
default:
|
||||
@@ -99,9 +100,10 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
|
||||
}
|
||||
}
|
||||
|
||||
// If info is expired, trigger a populateAlbumInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
||||
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
|
||||
enqueueRefresh(e.albumQueue, album)
|
||||
e.albumQueue.enqueue(*album)
|
||||
}
|
||||
|
||||
return &album.Album, nil
|
||||
@@ -164,7 +166,7 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist
|
||||
switch v := entity.(type) {
|
||||
case *model.Artist:
|
||||
artist.Artist = *v
|
||||
artist.Name = clearName(v.Name)
|
||||
artist.Name = str.Clear(v.Name)
|
||||
case *model.MediaFile:
|
||||
return e.getArtist(ctx, v.ArtistID)
|
||||
case *model.Album:
|
||||
@@ -175,17 +177,6 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
// Replace some Unicode chars with their equivalent ASCII
|
||||
func clearName(name string) string {
|
||||
name = strings.ReplaceAll(name, "–", "-")
|
||||
name = strings.ReplaceAll(name, "‐", "-")
|
||||
name = strings.ReplaceAll(name, "“", `"`)
|
||||
name = strings.ReplaceAll(name, "”", `"`)
|
||||
name = strings.ReplaceAll(name, "‘", `'`)
|
||||
name = strings.ReplaceAll(name, "’", `'`)
|
||||
return name
|
||||
}
|
||||
|
||||
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
artist, err := e.refreshArtistInfo(ctx, id)
|
||||
if err != nil {
|
||||
@@ -215,7 +206,7 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*a
|
||||
// If info is expired, trigger a populateArtistInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
|
||||
enqueueRefresh(e.artistQueue, artist)
|
||||
e.artistQueue.enqueue(*artist)
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
@@ -267,8 +258,8 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
weightedSongs := random.NewWeightedRandomChooser()
|
||||
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser, count, artistWeight int) error {
|
||||
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
|
||||
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return ctx.Err()
|
||||
@@ -302,12 +293,12 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
||||
|
||||
var similarSongs model.MediaFiles
|
||||
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
||||
s, err := weightedSongs.GetAndRemove()
|
||||
s, err := weightedSongs.Pick()
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting weighted song", err)
|
||||
continue
|
||||
}
|
||||
similarSongs = append(similarSongs, s.(model.MediaFile))
|
||||
similarSongs = append(similarSongs, s)
|
||||
}
|
||||
|
||||
return similarSongs, nil
|
||||
@@ -414,7 +405,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
||||
squirrel.Eq{"artist_id": artistID},
|
||||
squirrel.Eq{"album_artist_id": artistID},
|
||||
},
|
||||
squirrel.Like{"order_title": utils.SanitizeFieldForSorting(title)},
|
||||
squirrel.Like{"order_title": str.SanitizeFieldForSorting(title)},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc ",
|
||||
Max: 1,
|
||||
@@ -434,11 +425,11 @@ func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistUR
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, clearName(artist.Name), artist.MbzArtistID)
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
bio = utils.SanitizeText(bio)
|
||||
bio = str.SanitizeText(bio)
|
||||
bio = strings.ReplaceAll(bio, "\n", " ")
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
@@ -514,7 +505,7 @@ func (e *externalMetadata) findArtistByName(ctx context.Context, artistName stri
|
||||
}
|
||||
artist := &auxArtist{
|
||||
Artist: artists[0],
|
||||
Name: clearName(artists[0].Name),
|
||||
Name: str.Clear(artists[0].Name),
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
@@ -561,15 +552,17 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, c
|
||||
return nil
|
||||
}
|
||||
|
||||
func startRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) error) chan<- T {
|
||||
type refreshQueue[T any] chan<- T
|
||||
|
||||
func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context, *T) error) refreshQueue[T] {
|
||||
queue := make(chan T, refreshQueueLength)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(refreshDelay)
|
||||
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
|
||||
select {
|
||||
case a := <-queue:
|
||||
_ = processFn(ctx, a)
|
||||
case item := <-queue:
|
||||
_ = processFn(ctx, &item)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
@@ -580,9 +573,9 @@ func startRefreshQueue[T any](ctx context.Context, processFn func(context.Contex
|
||||
return queue
|
||||
}
|
||||
|
||||
func enqueueRefresh[T any](queue chan<- T, item T) {
|
||||
func (q *refreshQueue[T]) enqueue(item T) {
|
||||
select {
|
||||
case queue <- item:
|
||||
default: // It is ok to miss a refresh
|
||||
case *q <- item:
|
||||
default: // It is ok to miss a refresh request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -127,56 +128,64 @@ func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// TODO This function deserves some love (refactoring)
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
||||
format = "raw"
|
||||
if reqFormat == "raw" {
|
||||
return format, 0
|
||||
// selectTranscodingOptions selects the appropriate transcoding options based on the requested format and bitrate.
|
||||
// If the requested format is "raw" or matches the media file's suffix and the requested bitrate is 0, it returns the
|
||||
// original format and bitrate.
|
||||
// Otherwise, it determines the format and bitrate using determineFormatAndBitRate and findTranscoding functions.
|
||||
//
|
||||
// NOTE: It is easier to follow the tests in core/media_streamer_internal_test.go to understand the different scenarios.
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (string, int) {
|
||||
if reqFormat == "raw" || reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
return "raw", mf.BitRate
|
||||
}
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
return format, bitRate
|
||||
|
||||
format, bitRate := determineFormatAndBitRate(ctx, mf.BitRate, reqFormat, reqBitRate)
|
||||
if format == "" && bitRate == 0 {
|
||||
return "raw", 0
|
||||
}
|
||||
trc, hasDefault := request.TranscodingFrom(ctx)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
|
||||
return findTranscoding(ctx, ds, mf, format, bitRate)
|
||||
}
|
||||
|
||||
// determineFormatAndBitRate determines the format and bitrate for transcoding based on the requested format and bitrate.
|
||||
// If the requested format is not empty, it returns the requested format and bitrate.
|
||||
// Otherwise, it checks for default transcoding settings from the context or server configuration.
|
||||
func determineFormatAndBitRate(ctx context.Context, srcBitRate int, reqFormat string, reqBitRate int) (string, int) {
|
||||
if reqFormat != "" {
|
||||
cFormat = reqFormat
|
||||
} else {
|
||||
if hasDefault {
|
||||
cFormat = trc.TargetFormat
|
||||
cBitRate = trc.DefaultBitRate
|
||||
if p, ok := request.PlayerFrom(ctx); ok {
|
||||
cBitRate = p.MaxBitRate
|
||||
}
|
||||
} else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" {
|
||||
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
|
||||
// and there is no transcoding set for the player, we use the default downsampling format.
|
||||
// But only if the requested bitRate is lower than the original bitRate.
|
||||
log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat)
|
||||
cFormat = conf.Server.DefaultDownsamplingFormat
|
||||
return reqFormat, reqBitRate
|
||||
}
|
||||
|
||||
format, bitRate := "", 0
|
||||
if trc, hasDefault := request.TranscodingFrom(ctx); hasDefault {
|
||||
format = trc.TargetFormat
|
||||
bitRate = trc.DefaultBitRate
|
||||
|
||||
if p, ok := request.PlayerFrom(ctx); ok && p.MaxBitRate > 0 && p.MaxBitRate < bitRate {
|
||||
bitRate = p.MaxBitRate
|
||||
}
|
||||
} else if reqBitRate > 0 && reqBitRate < srcBitRate && conf.Server.DefaultDownsamplingFormat != "" {
|
||||
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
|
||||
// and there is no transcoding set for the player, we use the default downsampling format.
|
||||
// But only if the requested bitRate is lower than the original bitRate.
|
||||
log.Debug(ctx, "Using default downsampling format", "format", conf.Server.DefaultDownsamplingFormat)
|
||||
format = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
if reqBitRate > 0 {
|
||||
cBitRate = reqBitRate
|
||||
|
||||
return format, cmp.Or(reqBitRate, bitRate)
|
||||
}
|
||||
|
||||
// findTranscoding finds the appropriate transcoding settings for the given format and bitrate.
|
||||
// If the format matches the media file's suffix and the bitrate is greater than or equal to the original bitrate,
|
||||
// it returns the original format and bitrate.
|
||||
// Otherwise, it returns the target format and bitrate from the
|
||||
// transcoding settings.
|
||||
func findTranscoding(ctx context.Context, ds model.DataStore, mf *model.MediaFile, format string, bitRate int) (string, int) {
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err != nil || t == nil || format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
return "raw", 0
|
||||
}
|
||||
if cBitRate == 0 && cFormat == "" {
|
||||
return format, bitRate
|
||||
}
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
||||
if err == nil {
|
||||
format = t.TargetFormat
|
||||
if cBitRate != 0 {
|
||||
bitRate = cBitRate
|
||||
} else {
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
return format, bitRate
|
||||
|
||||
return t.TargetFormat, cmp.Or(bitRate, t.DefaultBitRate)
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -150,7 +150,8 @@ func (t *MpvTrack) Position() int {
|
||||
if retryCount > 5 {
|
||||
return 0
|
||||
}
|
||||
break
|
||||
time.Sleep(time.Duration(retryCount) * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -166,7 +167,6 @@ func (t *MpvTrack) Position() int {
|
||||
return int(pos)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t *MpvTrack) SetPosition(offset int) error {
|
||||
|
||||
@@ -104,7 +104,7 @@ func (pd *Queue) Shuffle() {
|
||||
var err error
|
||||
pd.Index, err = pd.getMediaFileIndexByID(backupID)
|
||||
if err != nil {
|
||||
log.Error("Could not find ID while shuffling: " + backupID)
|
||||
log.Error("Could not find ID while shuffling: %s", backupID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func (pd *Queue) getMediaFileIndexByID(id string) (int, error) {
|
||||
return idx, nil
|
||||
}
|
||||
}
|
||||
return -1, fmt.Errorf("ID not found in playlist: " + id)
|
||||
return -1, fmt.Errorf("ID not found in playlist: %s", id)
|
||||
}
|
||||
|
||||
// Sets the index to a new, valid value inside the Items. Values lower than zero are going to be zero,
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
type Players interface {
|
||||
Get(ctx context.Context, playerId string) (*model.Player, error)
|
||||
Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error)
|
||||
Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
|
||||
}
|
||||
|
||||
func NewPlayers(ds model.DataStore) Players {
|
||||
@@ -28,7 +28,7 @@ func (p *players) Register(ctx context.Context, id, client, userAgent, ip string
|
||||
var plr *model.Player
|
||||
var trc *model.Transcoding
|
||||
var err error
|
||||
userName, _ := request.UsernameFrom(ctx)
|
||||
user, _ := request.UserFrom(ctx)
|
||||
if id != "" {
|
||||
plr, err = p.ds.Player(ctx).Get(id)
|
||||
if err == nil && plr.Client != client {
|
||||
@@ -36,22 +36,22 @@ func (p *players) Register(ctx context.Context, id, client, userAgent, ip string
|
||||
}
|
||||
}
|
||||
if err != nil || id == "" {
|
||||
plr, err = p.ds.Player(ctx).FindMatch(userName, client, userAgent)
|
||||
plr, err = p.ds.Player(ctx).FindMatch(user.ID, client, userAgent)
|
||||
if err == nil {
|
||||
log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", userName, "type", userAgent)
|
||||
log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", userName(ctx), "type", userAgent)
|
||||
} else {
|
||||
plr = &model.Player{
|
||||
ID: uuid.NewString(),
|
||||
UserName: userName,
|
||||
UserId: user.ID,
|
||||
Client: client,
|
||||
ScrobbleEnabled: true,
|
||||
}
|
||||
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", userName, "type", userAgent)
|
||||
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", userName(ctx), "type", userAgent)
|
||||
}
|
||||
}
|
||||
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
|
||||
plr.UserAgent = userAgent
|
||||
plr.IPAddress = ip
|
||||
plr.IP = ip
|
||||
plr.LastSeen = time.Now()
|
||||
err = p.ds.Player(ctx).Put(plr)
|
||||
if err != nil {
|
||||
|
||||
@@ -34,7 +34,7 @@ var _ = Describe("Players", func() {
|
||||
Expect(p.ID).ToNot(BeEmpty())
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(p.Client).To(Equal("client"))
|
||||
Expect(p.UserName).To(Equal("johndoe"))
|
||||
Expect(p.UserId).To(Equal("userid"))
|
||||
Expect(p.UserAgent).To(Equal("chrome"))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
@@ -73,7 +73,7 @@ var _ = Describe("Players", func() {
|
||||
})
|
||||
|
||||
It("finds player by client and user names when ID is not found", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "999", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -83,7 +83,7 @@ var _ = Describe("Players", func() {
|
||||
})
|
||||
|
||||
It("finds player by client and user names when not ID is provided", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -102,6 +102,22 @@ var _ = Describe("Players", func() {
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc.ID).To(Equal("1"))
|
||||
})
|
||||
|
||||
Context("bad username casing", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"})
|
||||
ctx = request.WithUsername(ctx, "Johndoe")
|
||||
|
||||
It("finds player by client and user names when not ID is provided", func() {
|
||||
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,9 +141,9 @@ func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPlayerRepository) FindMatch(userName, client, typ string) (*model.Player, error) {
|
||||
func (m *mockPlayerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) {
|
||||
for _, p := range m.data {
|
||||
if p.Client == client && p.UserName == userName {
|
||||
if p.Client == client && p.UserId == userId && p.UserAgent == userAgent {
|
||||
return &p, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -15,10 +13,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
@@ -54,9 +54,9 @@ func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Pla
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: true,
|
||||
Sync: false,
|
||||
}
|
||||
pls, err := s.parseM3U(ctx, pls, "", reader)
|
||||
err := s.parseM3U(ctx, pls, "", reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
@@ -84,10 +84,11 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, base
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
return s.parseNSP(ctx, pls, file)
|
||||
err = s.parseNSP(ctx, pls, file)
|
||||
default:
|
||||
return s.parseM3U(ctx, pls, baseDir, file)
|
||||
err = s.parseM3U(ctx, pls, baseDir, file)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||
@@ -111,14 +112,14 @@ func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*mod
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) {
|
||||
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) error {
|
||||
nsp := &nspFile{}
|
||||
reader := jsoncommentstrip.NewReader(file)
|
||||
dec := json.NewDecoder(reader)
|
||||
err := dec.Decode(nsp)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
@@ -127,39 +128,50 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
return pls, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) (*model.Playlist, error) {
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) error {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Split(scanLines)
|
||||
var mfs model.MediaFiles
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "#PLAYLIST:") {
|
||||
if split := strings.Split(line, ":"); len(split) >= 2 {
|
||||
pls.Name = split[1]
|
||||
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) {
|
||||
filteredLines := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#PLAYLIST:") {
|
||||
pls.Name = line[len("#PLAYLIST:"):]
|
||||
continue
|
||||
}
|
||||
continue
|
||||
// Skip empty lines and extended info
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "file://") {
|
||||
line = strings.TrimPrefix(line, "file://")
|
||||
line, _ = url.QueryUnescape(line)
|
||||
}
|
||||
if baseDir != "" && !filepath.IsAbs(line) {
|
||||
line = filepath.Join(baseDir, line)
|
||||
}
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
// Skip empty lines and extended info
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "file://") {
|
||||
line = strings.TrimPrefix(line, "file://")
|
||||
line, _ = url.QueryUnescape(line)
|
||||
}
|
||||
if baseDir != "" && !filepath.IsAbs(line) {
|
||||
line = filepath.Join(baseDir, line)
|
||||
}
|
||||
mf, err := mediaFileRepository.FindByPath(line)
|
||||
found, err := mediaFileRepository.FindByPaths(filteredLines)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", line, err)
|
||||
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
||||
continue
|
||||
}
|
||||
mfs = append(mfs, *mf)
|
||||
existing := make(map[string]int, len(found))
|
||||
for idx := range found {
|
||||
existing[found[idx].Path] = idx
|
||||
}
|
||||
for _, path := range filteredLines {
|
||||
idx, ok := existing[path]
|
||||
if ok {
|
||||
mfs = append(mfs, found[idx])
|
||||
} else {
|
||||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
if pls.Name == "" {
|
||||
pls.Name = time.Now().Format(time.RFC3339)
|
||||
@@ -167,7 +179,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir s
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFiles(mfs)
|
||||
|
||||
return pls, scanner.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
@@ -193,34 +205,11 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
|
||||
// From https://stackoverflow.com/a/41433698
|
||||
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
|
||||
if data[i] == '\n' {
|
||||
// We have a line terminated by single newline.
|
||||
return i + 1, data[0:i], nil
|
||||
}
|
||||
advance = i + 1
|
||||
if len(data) > i+1 && data[i+1] == '\n' {
|
||||
advance += 1
|
||||
}
|
||||
return advance, data[0:i], nil
|
||||
}
|
||||
// If we're at EOF, we have a final, non-terminated line. Return it.
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
// Request more data.
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||
name *string, comment *string, public *bool,
|
||||
idsToAdd []string, idxToRemove []int) error {
|
||||
|
||||
@@ -3,19 +3,20 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds model.DataStore
|
||||
var ds *tests.MockDataStore
|
||||
var ps Playlists
|
||||
var mp mockedPlaylist
|
||||
ctx := context.Background()
|
||||
@@ -23,8 +24,7 @@ var _ = Describe("Playlists", func() {
|
||||
BeforeEach(func() {
|
||||
mp = mockedPlaylist{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: &mockedMediaFile{},
|
||||
MockedPlaylist: &mp,
|
||||
MockedPlaylist: &mp,
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
@@ -32,12 +32,13 @@ var _ = Describe("Playlists", func() {
|
||||
Describe("ImportFile", func() {
|
||||
BeforeEach(func() {
|
||||
ps = NewPlaylists(ds)
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||
})
|
||||
|
||||
Describe("M3U", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Tracks).To(HaveLen(3))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
|
||||
@@ -48,13 +49,13 @@ var _ = Describe("Playlists", func() {
|
||||
|
||||
It("parses playlists using LF ending", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("parses playlists using CR ending (old Mac format)", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
@@ -62,7 +63,7 @@ var _ = Describe("Playlists", func() {
|
||||
Describe("NSP", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/recently_played.nsp")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mp.last).To(Equal(pls))
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
@@ -76,18 +77,28 @@ var _ = Describe("Playlists", func() {
|
||||
})
|
||||
|
||||
Describe("ImportM3U", func() {
|
||||
var repo *mockedMediaFileFromListRepo
|
||||
BeforeEach(func() {
|
||||
repo = &mockedMediaFileFromListRepo{}
|
||||
ds.MockedMediaFile = repo
|
||||
ps = NewPlaylists(ds)
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
It("parses well-formed playlists", func() {
|
||||
f, _ := os.Open("tests/fixtures/playlists/pls-post-with-name.m3u")
|
||||
repo.data = []string{
|
||||
"tests/fixtures/test.mp3",
|
||||
"tests/fixtures/test.ogg",
|
||||
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
|
||||
}
|
||||
f, _ := os.Open("tests/fixtures/playlists/pls-with-name.m3u")
|
||||
defer f.Close()
|
||||
pls, err := ps.ImportM3U(ctx, f)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("playlist 1"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Sync).To(BeFalse())
|
||||
Expect(pls.Tracks).To(HaveLen(3))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
|
||||
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
|
||||
@@ -97,25 +108,74 @@ var _ = Describe("Playlists", func() {
|
||||
})
|
||||
|
||||
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
||||
f, _ := os.Open("tests/fixtures/playlists/pls-post.m3u")
|
||||
repo.data = []string{
|
||||
"tests/fixtures/test.mp3",
|
||||
"tests/fixtures/test.ogg",
|
||||
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
|
||||
}
|
||||
f, _ := os.Open("tests/fixtures/playlists/pls-without-name.m3u")
|
||||
defer f.Close()
|
||||
pls, err := ps.ImportM3U(ctx, f)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = time.Parse(time.RFC3339, pls.Name)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
|
||||
repo.data = []string{
|
||||
"test1.mp3",
|
||||
"test2.mp3",
|
||||
"test3.mp3",
|
||||
}
|
||||
m3u := strings.Join([]string{
|
||||
"test3.mp3",
|
||||
"test1.mp3",
|
||||
"test4.mp3",
|
||||
"test2.mp3",
|
||||
}, "\n")
|
||||
f := strings.NewReader(m3u)
|
||||
pls, err := ps.ImportM3U(ctx, f)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(3))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("test3.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("test1.mp3"))
|
||||
Expect(pls.Tracks[2].Path).To(Equal("test2.mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockedMediaFile struct {
|
||||
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
|
||||
type mockedMediaFileRepo struct {
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
|
||||
return &model.MediaFile{
|
||||
ID: "123",
|
||||
Path: s,
|
||||
}, nil
|
||||
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
var mfs model.MediaFiles
|
||||
for idx, path := range paths {
|
||||
mfs = append(mfs, model.MediaFile{
|
||||
ID: strconv.Itoa(idx),
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
// mockedMediaFileFromListRepo's FindByPaths method returns a list of MediaFiles based on the data field
|
||||
type mockedMediaFileFromListRepo struct {
|
||||
model.MediaFileRepository
|
||||
data []string
|
||||
}
|
||||
|
||||
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
|
||||
var mfs model.MediaFiles
|
||||
for idx, path := range r.data {
|
||||
mfs = append(mfs, model.MediaFile{
|
||||
ID: strconv.Itoa(idx),
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
type mockedPlaylist struct {
|
||||
|
||||
@@ -5,18 +5,16 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v2"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
const maxNowPlayingExpire = 60 * time.Minute
|
||||
|
||||
type NowPlayingInfo struct {
|
||||
MediaFile model.MediaFile
|
||||
Start time.Time
|
||||
@@ -39,7 +37,7 @@ type PlayTracker interface {
|
||||
type playTracker struct {
|
||||
ds model.DataStore
|
||||
broker events.Broker
|
||||
playMap *ttlcache.Cache
|
||||
playMap cache.SimpleCache[string, NowPlayingInfo]
|
||||
scrobblers map[string]Scrobbler
|
||||
}
|
||||
|
||||
@@ -52,9 +50,7 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
|
||||
// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
|
||||
// the GetPlayTracker function above
|
||||
func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
|
||||
m := ttlcache.NewCache()
|
||||
m.SkipTTLExtensionOnHit(true)
|
||||
_ = m.SetTTL(maxNowPlayingExpire)
|
||||
m := cache.NewSimpleCache[string, NowPlayingInfo]()
|
||||
p := &playTracker{ds: ds, playMap: m, broker: broker}
|
||||
p.scrobblers = make(map[string]Scrobbler)
|
||||
for name, constructor := range constructors {
|
||||
@@ -84,7 +80,7 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
|
||||
}
|
||||
|
||||
ttl := time.Duration(int(mf.Duration)+5) * time.Second
|
||||
_ = p.playMap.SetWithTTL(playerId, info, ttl)
|
||||
_ = p.playMap.AddWithTTL(playerId, info, ttl)
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if player.ScrobbleEnabled {
|
||||
p.dispatchNowPlaying(ctx, user.ID, mf)
|
||||
@@ -111,15 +107,7 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *
|
||||
}
|
||||
|
||||
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
|
||||
var res []NowPlayingInfo
|
||||
for _, playerId := range p.playMap.GetKeys() {
|
||||
value, err := p.playMap.Get(playerId)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info := value.(NowPlayingInfo)
|
||||
res = append(res, info)
|
||||
}
|
||||
res := p.playMap.Values()
|
||||
sort.Slice(res, func(i, j int) bool {
|
||||
return res[i].Start.After(res[j].Start)
|
||||
})
|
||||
@@ -130,7 +118,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if !player.ScrobbleEnabled {
|
||||
log.Debug(ctx, "External scrobbling disabled for this player", "player", player.Name, "ip", player.IPAddress, "user", username)
|
||||
log.Debug(ctx, "External scrobbling disabled for this player", "player", player.Name, "ip", player.IP, "user", username)
|
||||
}
|
||||
event := &events.RefreshResource{}
|
||||
success := 0
|
||||
@@ -162,15 +150,15 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
|
||||
|
||||
func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error {
|
||||
return p.ds.WithTx(func(tx model.DataStore) error {
|
||||
err := p.ds.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
|
||||
err := tx.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = p.ds.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
|
||||
err = tx.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = p.ds.Artist(ctx).IncPlayCount(track.ArtistID, timestamp)
|
||||
err = tx.Artist(ctx).IncPlayCount(track.ArtistID, timestamp)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
74
db/db.go
74
db/db.go
@@ -4,11 +4,13 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
_ "github.com/navidrome/navidrome/db/migrations"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/hasher"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
@@ -23,29 +25,77 @@ var embedMigrations embed.FS
|
||||
|
||||
const migrationsFolder = "migrations"
|
||||
|
||||
func Db() *sql.DB {
|
||||
return singleton.GetInstance(func() *sql.DB {
|
||||
type DB interface {
|
||||
ReadDB() *sql.DB
|
||||
WriteDB() *sql.DB
|
||||
Close()
|
||||
}
|
||||
|
||||
type db struct {
|
||||
readDB *sql.DB
|
||||
writeDB *sql.DB
|
||||
}
|
||||
|
||||
func (d *db) ReadDB() *sql.DB {
|
||||
return d.readDB
|
||||
}
|
||||
|
||||
func (d *db) WriteDB() *sql.DB {
|
||||
return d.writeDB
|
||||
}
|
||||
|
||||
func (d *db) Close() {
|
||||
if err := d.readDB.Close(); err != nil {
|
||||
log.Error("Error closing read DB", err)
|
||||
}
|
||||
if err := d.writeDB.Close(); err != nil {
|
||||
log.Error("Error closing write DB", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Db() DB {
|
||||
return singleton.GetInstance(func() *db {
|
||||
sql.Register(Driver+"_custom", &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
|
||||
},
|
||||
})
|
||||
|
||||
Path = conf.Server.DbPath
|
||||
if Path == ":memory:" {
|
||||
Path = "file::memory:?cache=shared&_foreign_keys=on"
|
||||
conf.Server.DbPath = Path
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||
instance, err := sql.Open(Driver, Path)
|
||||
|
||||
// Create a read database connection
|
||||
rdb, err := sql.Open(Driver+"_custom", Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatal("Error opening read database", err)
|
||||
}
|
||||
rdb.SetMaxOpenConns(max(4, runtime.NumCPU()))
|
||||
|
||||
// Create a write database connection
|
||||
wdb, err := sql.Open(Driver+"_custom", Path)
|
||||
if err != nil {
|
||||
log.Fatal("Error opening write database", err)
|
||||
}
|
||||
wdb.SetMaxOpenConns(1)
|
||||
|
||||
return &db{
|
||||
readDB: rdb,
|
||||
writeDB: wdb,
|
||||
}
|
||||
return instance
|
||||
})
|
||||
}
|
||||
|
||||
func Close() error {
|
||||
func Close() {
|
||||
log.Info("Closing Database")
|
||||
return Db().Close()
|
||||
Db().Close()
|
||||
}
|
||||
|
||||
func Init() func() {
|
||||
db := Db()
|
||||
db := Db().WriteDB()
|
||||
|
||||
// Disable foreign_keys to allow re-creating tables in migrations
|
||||
_, err := db.Exec("PRAGMA foreign_keys=off")
|
||||
@@ -75,11 +125,7 @@ func Init() func() {
|
||||
log.Fatal("Failed to apply new migrations", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
if err := Close(); err != nil {
|
||||
log.Error("Error closing DB", err)
|
||||
}
|
||||
}
|
||||
return Close
|
||||
}
|
||||
|
||||
type statusLogger struct{ numPending int }
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all := utils.SanitizeStrings(artistId, albumArtistId, songArtistIds.String)
|
||||
all := str.SanitizeStrings(artistId, albumArtistId, songArtistIds.String)
|
||||
_, err = stmt.Exec(all, id)
|
||||
if err != nil {
|
||||
log.Error("Error setting album's artist_ids", "album", name, "albumId", id, err)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
@@ -33,8 +33,8 @@ func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
newComment := utils.SanitizeText(comment.String)
|
||||
newLyrics := utils.SanitizeText(lyrics.String)
|
||||
newComment := str.SanitizeText(comment.String)
|
||||
newLyrics := str.SanitizeText(lyrics.String)
|
||||
_, err = stmt.Exec(newComment, newLyrics, id)
|
||||
if err != nil {
|
||||
log.Error("Error unescaping media_file's lyrics and comments", "title", title, "id", id, err)
|
||||
|
||||
71
db/migrations/20240511220020_add_library_table.go
Normal file
71
db/migrations/20240511220020_add_library_table.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddLibraryTable, downAddLibraryTable)
|
||||
}
|
||||
|
||||
func upAddLibraryTable(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table library (
|
||||
id integer primary key autoincrement,
|
||||
name text not null unique,
|
||||
path text not null unique,
|
||||
remote_path text null default '',
|
||||
last_scan_at datetime not null default '0000-00-00 00:00:00',
|
||||
updated_at datetime not null default current_timestamp,
|
||||
created_at datetime not null default current_timestamp
|
||||
);`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
|
||||
insert into library(id, name, path, last_scan_at) values(1, 'Music Library', '%s', current_timestamp);
|
||||
delete from property where id like 'LastScan-%%';
|
||||
`, conf.Server.MusicFolder))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
alter table media_file add column library_id integer not null default 1
|
||||
references library(id) on delete cascade;
|
||||
alter table album add column library_id integer not null default 1
|
||||
references library(id) on delete cascade;
|
||||
|
||||
create table if not exists library_artist
|
||||
(
|
||||
library_id integer not null default 1
|
||||
references library(id)
|
||||
on delete cascade,
|
||||
artist_id varchar not null default null
|
||||
references artist(id)
|
||||
on delete cascade,
|
||||
constraint library_artist_ux
|
||||
unique (library_id, artist_id)
|
||||
);
|
||||
|
||||
insert into library_artist(library_id, artist_id) select 1, id from artist;
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddLibraryTable(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file drop column library_id;
|
||||
alter table album drop column library_id;
|
||||
drop table library_artist;
|
||||
drop table library;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
66
db/migrations/20240629152843_remove_annotation_id.go
Normal file
66
db/migrations/20240629152843_remove_annotation_id.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upRemoveAnnotationId, downRemoveAnnotationId)
|
||||
}
|
||||
|
||||
func upRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table annotation_dg_tmp
|
||||
(
|
||||
user_id varchar(255) default '' not null,
|
||||
item_id varchar(255) default '' not null,
|
||||
item_type varchar(255) default '' not null,
|
||||
play_count integer default 0,
|
||||
play_date datetime,
|
||||
rating integer default 0,
|
||||
starred bool default FALSE not null,
|
||||
starred_at datetime,
|
||||
unique (user_id, item_id, item_type)
|
||||
);
|
||||
|
||||
insert into annotation_dg_tmp(user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at)
|
||||
select user_id,
|
||||
item_id,
|
||||
item_type,
|
||||
play_count,
|
||||
play_date,
|
||||
rating,
|
||||
starred,
|
||||
starred_at
|
||||
from annotation;
|
||||
|
||||
drop table annotation;
|
||||
|
||||
alter table annotation_dg_tmp
|
||||
rename to annotation;
|
||||
|
||||
create index annotation_play_count
|
||||
on annotation (play_count);
|
||||
|
||||
create index annotation_play_date
|
||||
on annotation (play_date);
|
||||
|
||||
create index annotation_rating
|
||||
on annotation (rating);
|
||||
|
||||
create index annotation_starred
|
||||
on annotation (starred);
|
||||
|
||||
create index annotation_starred_at
|
||||
on annotation (starred_at);
|
||||
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upPlayerUseUserIdOverUsername, downPlayerUseUserIdOverUsername)
|
||||
}
|
||||
|
||||
func upPlayerUseUserIdOverUsername(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
CREATE TABLE player_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar not null,
|
||||
user_agent varchar,
|
||||
user_id varchar not null
|
||||
references user (id)
|
||||
on update cascade on delete cascade,
|
||||
client varchar not null,
|
||||
ip varchar,
|
||||
last_seen timestamp,
|
||||
max_bit_rate int default 0,
|
||||
transcoding_id varchar,
|
||||
report_real_path bool default FALSE not null,
|
||||
scrobble_enabled bool default true
|
||||
);
|
||||
|
||||
INSERT INTO player_dg_tmp(
|
||||
id, name, user_agent, user_id, client, ip, last_seen, max_bit_rate,
|
||||
transcoding_id, report_real_path, scrobble_enabled
|
||||
)
|
||||
SELECT
|
||||
id, name, user_agent,
|
||||
IFNULL(
|
||||
(select id from user where user_name = player.user_name), 'UNKNOWN_USERNAME'
|
||||
),
|
||||
client, ip_address, last_seen, max_bit_rate, transcoding_id, report_real_path, scrobble_enabled
|
||||
FROM player;
|
||||
|
||||
DELETE FROM player_dg_tmp WHERE user_id = 'UNKNOWN_USERNAME';
|
||||
DROP TABLE player;
|
||||
ALTER TABLE player_dg_tmp RENAME TO player;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS player_match
|
||||
on player (client, user_agent, user_id);
|
||||
CREATE INDEX IF NOT EXISTS player_name
|
||||
on player (name);
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func downPlayerUseUserIdOverUsername(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
87
go.mod
87
go.mod
@@ -1,6 +1,8 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.21
|
||||
go 1.23
|
||||
|
||||
toolchain go1.23.1
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
@@ -12,82 +14,85 @@ require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/atime v1.1.0
|
||||
github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d
|
||||
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4
|
||||
github.com/djherbis/stream v1.4.0
|
||||
github.com/djherbis/times v1.6.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/go-chi/chi/v5 v5.0.12
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-chi/httprate v0.9.0
|
||||
github.com/go-chi/httprate v0.14.1
|
||||
github.com/go-chi/jwtauth/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.6.0
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/jellydator/ttlcache/v2 v2.11.1
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.0.21
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/mattn/go-zglob v0.0.4
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/mileusna/useragent v1.3.4
|
||||
github.com/onsi/ginkgo/v2 v2.17.3
|
||||
github.com/onsi/gomega v1.33.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.2
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.1
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.23
|
||||
github.com/mattn/go-zglob v0.0.6
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.20.2
|
||||
github.com/onsi/gomega v1.34.2
|
||||
github.com/pelletier/go-toml/v2 v2.2.3
|
||||
github.com/pocketbase/dbx v1.10.1
|
||||
github.com/pressly/goose/v3 v3.20.0
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
github.com/pressly/goose/v3 v3.22.1
|
||||
github.com/prometheus/client_golang v1.20.4
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/unrolled/secure v1.14.0
|
||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842
|
||||
golang.org/x/image v0.16.0
|
||||
golang.org/x/sync v0.7.0
|
||||
golang.org/x/text v0.15.0
|
||||
github.com/unrolled/secure v1.15.0
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
||||
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948
|
||||
golang.org/x/image v0.20.0
|
||||
golang.org/x/sync v0.8.0
|
||||
golang.org/x/text v0.18.0
|
||||
golang.org/x/time v0.6.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.5 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.2.4 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
@@ -95,11 +100,11 @@ require (
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/tools v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
golang.org/x/crypto v0.27.0 // indirect
|
||||
golang.org/x/net v0.28.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/tools v0.24.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
212
go.sum
212
go.sum
@@ -10,16 +10,16 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
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-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+dbf7JA6CAaM2UH/AGP1KX4DsJmTI=
|
||||
@@ -32,8 +32,8 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g=
|
||||
github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE=
|
||||
github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d h1:eAikRiT337jlFa/NSKGb7K0uoP8/cana3EXzIDyFI6E=
|
||||
github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d/go.mod h1:+uJNKpxCg52qVRGr+srICjiY8QvV0riatTzCGMUuSEY=
|
||||
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc=
|
||||
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4/go.mod h1:dHWjlanKIxaHVH1xJOTb4kzP800XdcXlgJ6JYlR2DPU=
|
||||
github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE=
|
||||
github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
@@ -46,29 +46,29 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
|
||||
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.9.0 h1:21A+4WDMDA5FyWcg7mNrhj63aNT8CGh+Z1alOE/piU8=
|
||||
github.com/go-chi/httprate v0.9.0/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A=
|
||||
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
|
||||
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh5A=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg=
|
||||
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
|
||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -76,10 +76,11 @@ github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
@@ -89,18 +90,19 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jellydator/ttlcache/v2 v2.11.1 h1:AZGME43Eh2Vv3giG6GeqeLeFXxwxn1/qHItqWZl6U64=
|
||||
github.com/jellydator/ttlcache/v2 v2.11.1/go.mod h1:RtE5Snf0/57e+2cLWFYWCCsLas2Hy3c5Z4n14XmSvTI=
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
@@ -109,57 +111,58 @@ github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk=
|
||||
github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||
github.com/lestrrat-go/jwx/v2 v2.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0=
|
||||
github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.1 h1:Y2ltVl8J6izLYFs54BVcpXLv5msSW4o8eXwnzZLI32E=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.1/go.mod h1:4LvZg7oxu6Q5VJwn7Mk/UwooNRnTHUpXBj2C4j3HNx0=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-zglob v0.0.4 h1:LQi2iOm0/fGgu80AioIJ/1j9w9Oh+9DZ39J4VAGzHQM=
|
||||
github.com/mattn/go-zglob v0.0.4/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A=
|
||||
github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY=
|
||||
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/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk=
|
||||
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/onsi/ginkgo/v2 v2.17.3 h1:oJcvKpIb7/8uLpDDtnQuf18xVnwKp8DTD7DQ6gTd/MU=
|
||||
github.com/onsi/ginkgo/v2 v2.17.3/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc=
|
||||
github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
|
||||
github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4=
|
||||
github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag=
|
||||
github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
|
||||
github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
|
||||
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pressly/goose/v3 v3.20.0 h1:uPJdOxF/Ipj7ABVNOAMJXSxwFXZGwMGHNqjC8e61VA0=
|
||||
github.com/pressly/goose/v3 v3.20.0/go.mod h1:BRfF2GcG4FTG12QfdBVy3q1yveaf4ckL9vWwEcIO3lA=
|
||||
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc=
|
||||
github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo=
|
||||
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
|
||||
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
@@ -174,8 +177,8 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec=
|
||||
github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
@@ -189,16 +192,14 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
@@ -206,70 +207,54 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/unrolled/secure v1.14.0 h1:u9vJTU/pR4Bny0ntLUMxdfLtmIRGvQf2sEFuA0TG9AE=
|
||||
github.com/unrolled/secure v1.14.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
|
||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9Og=
|
||||
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
|
||||
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
|
||||
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
|
||||
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI=
|
||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
|
||||
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -280,8 +265,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -296,29 +281,24 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
|
||||
golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
@@ -331,14 +311,12 @@ 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/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
|
||||
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4=
|
||||
modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.33.0 h1:WWkA/T2G17okiLGgKAj4/RMIvgyMT19yQ038160IeYk=
|
||||
modernc.org/sqlite v1.33.0/go.mod h1:9uQ9hF/pCZoYZK73D/ud5Z7cIRIILSZI8NdIemVMTX8=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
11
log/log.go
11
log/log.go
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Level uint8
|
||||
type Level uint32
|
||||
|
||||
type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{})
|
||||
|
||||
@@ -109,6 +109,7 @@ func levelFromString(l string) Level {
|
||||
|
||||
// SetLogLevels sets the log levels for specific paths in the codebase.
|
||||
func SetLogLevels(levels map[string]string) {
|
||||
logLevels = nil
|
||||
for k, v := range levels {
|
||||
logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)})
|
||||
}
|
||||
@@ -158,7 +159,7 @@ func CurrentLevel() Level {
|
||||
|
||||
// IsGreaterOrEqualTo returns true if the caller's current log level is equal or greater than the provided level.
|
||||
func IsGreaterOrEqualTo(level Level) bool {
|
||||
return shouldLog(level)
|
||||
return shouldLog(level, 2)
|
||||
}
|
||||
|
||||
func Fatal(args ...interface{}) {
|
||||
@@ -187,14 +188,14 @@ func Trace(args ...interface{}) {
|
||||
}
|
||||
|
||||
func log(level Level, args ...interface{}) {
|
||||
if !shouldLog(level) {
|
||||
if !shouldLog(level, 3) {
|
||||
return
|
||||
}
|
||||
logger, msg := parseArgs(args)
|
||||
logger.Log(logrus.Level(level), msg)
|
||||
}
|
||||
|
||||
func shouldLog(requiredLevel Level) bool {
|
||||
func shouldLog(requiredLevel Level, skip int) bool {
|
||||
if currentLevel >= requiredLevel {
|
||||
return true
|
||||
}
|
||||
@@ -202,7 +203,7 @@ func shouldLog(requiredLevel Level) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
_, file, _, ok := runtime.Caller(3)
|
||||
_, file, _, ok := runtime.Caller(skip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -150,6 +150,10 @@ var _ = Describe("Logger", func() {
|
||||
})
|
||||
|
||||
Describe("IsGreaterOrEqualTo", func() {
|
||||
BeforeEach(func() {
|
||||
SetLogLevels(nil)
|
||||
})
|
||||
|
||||
It("returns false if log level is below provided level", func() {
|
||||
SetLevel(LevelError)
|
||||
Expect(IsGreaterOrEqualTo(LevelWarn)).To(BeFalse())
|
||||
|
||||
@@ -12,6 +12,7 @@ type Album struct {
|
||||
Annotations `structs:"-"`
|
||||
|
||||
ID string `structs:"id" json:"id"`
|
||||
LibraryID int `structs:"library_id" json:"libraryId"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
EmbedArtPath string `structs:"embed_art_path" json:"embedArtPath"`
|
||||
ArtistID string `structs:"artist_id" json:"artistId"`
|
||||
@@ -35,7 +36,7 @@ type Album struct {
|
||||
Genre string `structs:"genre" json:"genre"`
|
||||
Genres Genres `structs:"-" json:"genres"`
|
||||
Discs Discs `structs:"discs" json:"discs,omitempty"`
|
||||
FullText string `structs:"full_text" json:"fullText"`
|
||||
FullText string `structs:"full_text" json:"-"`
|
||||
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
|
||||
|
||||
@@ -3,11 +3,11 @@ package model
|
||||
import "time"
|
||||
|
||||
type Annotations struct {
|
||||
PlayCount int64 `structs:"-" json:"playCount"`
|
||||
PlayDate *time.Time `structs:"-" json:"playDate" `
|
||||
Rating int `structs:"-" json:"rating" `
|
||||
Starred bool `structs:"-" json:"starred" `
|
||||
StarredAt *time.Time `structs:"-" json:"starredAt"`
|
||||
PlayCount int64 `structs:"play_count" json:"playCount"`
|
||||
PlayDate *time.Time `structs:"play_date" json:"playDate" `
|
||||
Rating int `structs:"rating" json:"rating" `
|
||||
Starred bool `structs:"starred" json:"starred" `
|
||||
StarredAt *time.Time `structs:"starred_at" json:"starredAt"`
|
||||
}
|
||||
|
||||
type AnnotatedRepository interface {
|
||||
|
||||
@@ -10,7 +10,7 @@ type Artist struct {
|
||||
AlbumCount int `structs:"album_count" json:"albumCount"`
|
||||
SongCount int `structs:"song_count" json:"songCount"`
|
||||
Genres Genres `structs:"-" json:"genres"`
|
||||
FullText string `structs:"full_text" json:"fullText"`
|
||||
FullText string `structs:"full_text" json:"-"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
|
||||
@@ -50,6 +50,21 @@ func (c Criteria) ToSql() (sql string, args []interface{}, err error) {
|
||||
return c.Expression.ToSql()
|
||||
}
|
||||
|
||||
func (c Criteria) ChildPlaylistIds() (ids []string) {
|
||||
if c.Expression == nil {
|
||||
return ids
|
||||
}
|
||||
|
||||
switch rules := c.Expression.(type) {
|
||||
case Any:
|
||||
ids = rules.ChildPlaylistIds()
|
||||
case All:
|
||||
ids = rules.ChildPlaylistIds()
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
func (c Criteria) MarshalJSON() ([]byte, error) {
|
||||
aux := struct {
|
||||
All []Expression `json:"all,omitempty"`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
@@ -65,7 +66,7 @@ var _ = Describe("Criteria", func() {
|
||||
sql, args, err := goObj.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("(media_file.title LIKE ? AND media_file.title NOT LIKE ? AND (media_file.artist <> ? OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) AND COALESCE(genre.name, '') <> ?))"))
|
||||
gomega.Expect(args).To(gomega.ConsistOf("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "test"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "test"))
|
||||
})
|
||||
|
||||
It("marshals to JSON", func() {
|
||||
@@ -89,4 +90,94 @@ var _ = Describe("Criteria", func() {
|
||||
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
|
||||
})
|
||||
|
||||
It("extracts all child smart playlist IDs from All expression criteria", func() {
|
||||
topLevelInPlaylistID := uuid.NewString()
|
||||
topLevelNotInPlaylistID := uuid.NewString()
|
||||
|
||||
nestedAnyInPlaylistID := uuid.NewString()
|
||||
nestedAnyNotInPlaylistID := uuid.NewString()
|
||||
|
||||
nestedAllInPlaylistID := uuid.NewString()
|
||||
nestedAllNotInPlaylistID := uuid.NewString()
|
||||
|
||||
goObj := Criteria{
|
||||
Expression: All{
|
||||
InPlaylist{"id": topLevelInPlaylistID},
|
||||
NotInPlaylist{"id": topLevelNotInPlaylistID},
|
||||
Any{
|
||||
InPlaylist{"id": nestedAnyInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
|
||||
},
|
||||
All{
|
||||
InPlaylist{"id": nestedAllInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAllNotInPlaylistID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ids := goObj.ChildPlaylistIds()
|
||||
|
||||
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
|
||||
})
|
||||
|
||||
It("extracts all child smart playlist IDs from Any expression criteria", func() {
|
||||
topLevelInPlaylistID := uuid.NewString()
|
||||
topLevelNotInPlaylistID := uuid.NewString()
|
||||
|
||||
nestedAnyInPlaylistID := uuid.NewString()
|
||||
nestedAnyNotInPlaylistID := uuid.NewString()
|
||||
|
||||
nestedAllInPlaylistID := uuid.NewString()
|
||||
nestedAllNotInPlaylistID := uuid.NewString()
|
||||
|
||||
goObj := Criteria{
|
||||
Expression: Any{
|
||||
InPlaylist{"id": topLevelInPlaylistID},
|
||||
NotInPlaylist{"id": topLevelNotInPlaylistID},
|
||||
Any{
|
||||
InPlaylist{"id": nestedAnyInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
|
||||
},
|
||||
All{
|
||||
InPlaylist{"id": nestedAllInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAllNotInPlaylistID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ids := goObj.ChildPlaylistIds()
|
||||
|
||||
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
|
||||
})
|
||||
|
||||
It("extracts child smart playlist IDs from deeply nested expression", func() {
|
||||
nestedAnyInPlaylistID := uuid.NewString()
|
||||
nestedAnyNotInPlaylistID := uuid.NewString()
|
||||
|
||||
nestedAllInPlaylistID := uuid.NewString()
|
||||
nestedAllNotInPlaylistID := uuid.NewString()
|
||||
|
||||
goObj := Criteria{
|
||||
Expression: Any{
|
||||
Any{
|
||||
All{
|
||||
Any{
|
||||
InPlaylist{"id": nestedAnyInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
|
||||
Any{
|
||||
All{
|
||||
InPlaylist{"id": nestedAllInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAllNotInPlaylistID},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ids := goObj.ChildPlaylistIds()
|
||||
|
||||
gomega.Expect(ids).To(gomega.ConsistOf(nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,10 @@ func (all All) MarshalJSON() ([]byte, error) {
|
||||
return marshalConjunction("all", all)
|
||||
}
|
||||
|
||||
func (all All) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(all)
|
||||
}
|
||||
|
||||
type (
|
||||
Any squirrel.Or
|
||||
Or = Any
|
||||
@@ -36,6 +40,10 @@ func (any Any) MarshalJSON() ([]byte, error) {
|
||||
return marshalConjunction("any", any)
|
||||
}
|
||||
|
||||
func (any Any) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(any)
|
||||
}
|
||||
|
||||
type Is squirrel.Eq
|
||||
type Eq = Is
|
||||
|
||||
@@ -275,3 +283,29 @@ func inList(m map[string]interface{}, negate bool) (sql string, args []interface
|
||||
return "media_file.id IN (" + subQText + ")", subQArgs, nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractPlaylistIds(inputRule interface{}) (ids []string) {
|
||||
var id string
|
||||
var ok bool
|
||||
|
||||
switch rule := inputRule.(type) {
|
||||
case Any:
|
||||
for _, rules := range rule {
|
||||
ids = append(ids, extractPlaylistIds(rules)...)
|
||||
}
|
||||
case All:
|
||||
for _, rules := range rule {
|
||||
ids = append(ids, extractPlaylistIds(rules)...)
|
||||
}
|
||||
case InPlaylist:
|
||||
if id, ok = rule["id"].(string); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
case NotInPlaylist:
|
||||
if id, ok = rule["id"].(string); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ var _ = Describe("Operators", func() {
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal(expectedSql))
|
||||
gomega.Expect(args).To(gomega.ConsistOf(expectedArgs...))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...))
|
||||
},
|
||||
Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
|
||||
Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
|
||||
|
||||
@@ -13,6 +13,7 @@ type QueryOptions struct {
|
||||
Max int
|
||||
Offset int
|
||||
Filters squirrel.Sqlizer
|
||||
Seed string // for random sorting
|
||||
}
|
||||
|
||||
type ResourceRepository interface {
|
||||
@@ -20,10 +21,10 @@ type ResourceRepository interface {
|
||||
}
|
||||
|
||||
type DataStore interface {
|
||||
Library(ctx context.Context) LibraryRepository
|
||||
Album(ctx context.Context) AlbumRepository
|
||||
Artist(ctx context.Context) ArtistRepository
|
||||
MediaFile(ctx context.Context) MediaFileRepository
|
||||
MediaFolder(ctx context.Context) MediaFolderRepository
|
||||
Genre(ctx context.Context) GenreRepository
|
||||
Playlist(ctx context.Context) PlaylistRepository
|
||||
PlayQueue(ctx context.Context) PlayQueueRepository
|
||||
|
||||
32
model/library.go
Normal file
32
model/library.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Library struct {
|
||||
ID int
|
||||
Name string
|
||||
Path string
|
||||
RemotePath string
|
||||
LastScanAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (f Library) FS() fs.FS {
|
||||
return os.DirFS(f.Path)
|
||||
}
|
||||
|
||||
type Libraries []Library
|
||||
|
||||
type LibraryRepository interface {
|
||||
Get(id int) (*Library, error)
|
||||
Put(*Library) error
|
||||
StoreMusicFolder() error
|
||||
AddArtist(id int, artistID string) error
|
||||
UpdateLastScan(id int, t time.Time) error
|
||||
GetAll(...QueryOptions) (Libraries, error)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type Line struct {
|
||||
@@ -36,7 +36,7 @@ var (
|
||||
)
|
||||
|
||||
func ToLyrics(language, text string) (*Lyrics, error) {
|
||||
text = utils.SanitizeText(text)
|
||||
text = str.SanitizeText(text)
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
@@ -67,7 +67,7 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
||||
if idTag != nil {
|
||||
switch idTag[1] {
|
||||
case "ar":
|
||||
artist = utils.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
artist = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
case "offset":
|
||||
{
|
||||
off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64)
|
||||
@@ -78,7 +78,7 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
||||
}
|
||||
}
|
||||
case "ti":
|
||||
title = utils.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
title = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
}
|
||||
|
||||
continue
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type MediaFile struct {
|
||||
@@ -21,6 +21,7 @@ type MediaFile struct {
|
||||
Bookmarkable `structs:"-"`
|
||||
|
||||
ID string `structs:"id" json:"id"`
|
||||
LibraryID int `structs:"library_id" json:"libraryId"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Title string `structs:"title" json:"title"`
|
||||
Album string `structs:"album" json:"album"`
|
||||
@@ -47,7 +48,7 @@ type MediaFile struct {
|
||||
Channels int `structs:"channels" json:"channels"`
|
||||
Genre string `structs:"genre" json:"genre"`
|
||||
Genres Genres `structs:"-" json:"genres"`
|
||||
FullText string `structs:"full_text" json:"fullText"`
|
||||
FullText string `structs:"full_text" json:"-"`
|
||||
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
|
||||
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||
@@ -186,7 +187,7 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
a.Genre = slice.MostFrequent(a.Genres).Name
|
||||
slices.SortFunc(a.Genres, func(a, b Genre) int { return cmp.Compare(a.ID, b.ID) })
|
||||
a.Genres = slices.Compact(a.Genres)
|
||||
a.FullText = " " + utils.SanitizeStrings(fullText...)
|
||||
a.FullText = " " + str.SanitizeStrings(fullText...)
|
||||
a = fixAlbumArtist(a, albumArtistIds)
|
||||
songArtistIds = append(songArtistIds, a.AlbumArtistID, a.ArtistID)
|
||||
slices.Sort(songArtistIds)
|
||||
@@ -197,15 +198,12 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
}
|
||||
|
||||
func allOrNothing(items []string) (string, int) {
|
||||
sort.Strings(items)
|
||||
items = slices.Compact(items)
|
||||
if len(items) == 1 {
|
||||
return items[0], 1
|
||||
}
|
||||
if len(items) > 1 {
|
||||
sort.Strings(items)
|
||||
if len(items) != 1 {
|
||||
return "", len(slices.Compact(items))
|
||||
}
|
||||
return "", 0
|
||||
return items[0], 1
|
||||
}
|
||||
|
||||
func minMax(items []int) (int, int) {
|
||||
@@ -267,6 +265,7 @@ type MediaFileRepository interface {
|
||||
// Queries by path to support the scanner, no Annotations or Bookmarks required in the response
|
||||
FindAllByPath(path string) (MediaFiles, error)
|
||||
FindByPath(path string) (*MediaFile, error)
|
||||
FindByPaths(paths []string) (MediaFiles, error)
|
||||
FindPathsRecursively(basePath string) ([]string, error)
|
||||
DeleteByPath(path string) (int64, error)
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
type MediaFolder struct {
|
||||
ID int32
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
func (f MediaFolder) FS() fs.FS {
|
||||
return os.DirFS(f.Path)
|
||||
}
|
||||
|
||||
type MediaFolders []MediaFolder
|
||||
|
||||
type MediaFolderRepository interface {
|
||||
Get(id int32) (*MediaFolder, error)
|
||||
GetAll() (MediaFolders, error)
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
)
|
||||
|
||||
type Player struct {
|
||||
Username string `structs:"-" json:"userName"`
|
||||
|
||||
ID string `structs:"id" json:"id"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
UserAgent string `structs:"user_agent" json:"userAgent"`
|
||||
UserName string `structs:"user_name" json:"userName"`
|
||||
UserId string `structs:"user_id" json:"userId"`
|
||||
Client string `structs:"client" json:"client"`
|
||||
IPAddress string `structs:"ip_address" json:"ipAddress"`
|
||||
IP string `structs:"ip" json:"ip"`
|
||||
LastSeen time.Time `structs:"last_seen" json:"lastSeen"`
|
||||
TranscodingId string `structs:"transcoding_id" json:"transcodingId"`
|
||||
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"`
|
||||
@@ -22,7 +24,7 @@ type Players []Player
|
||||
|
||||
type PlayerRepository interface {
|
||||
Get(id string) (*Player, error)
|
||||
FindMatch(userName, client, typ string) (*Player, error)
|
||||
FindMatch(userId, client, userAgent string) (*Player, error)
|
||||
Put(p *Player) error
|
||||
// TODO: Add CountAll method. Useful at least for metrics.
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package model
|
||||
|
||||
const (
|
||||
// TODO Move other prop keys to here
|
||||
PropLastScan = "LastScan"
|
||||
)
|
||||
|
||||
type PropertyRepository interface {
|
||||
Put(id string, value string) error
|
||||
Get(id string) (string, error)
|
||||
|
||||
@@ -42,7 +42,7 @@ func (s Share) CoverArtID() ArtworkID {
|
||||
case "artist":
|
||||
return Artist{ID: ids[0]}.CoverArtID()
|
||||
}
|
||||
rnd := random.Int64(len(s.Tracks))
|
||||
rnd := random.Int64N(len(s.Tracks))
|
||||
return s.Tracks[rnd].CoverArtID()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
type albumRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
type dbAlbum struct {
|
||||
@@ -59,7 +58,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "album"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
r.registerModel(&model.Album{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"compilation": booleanFilter,
|
||||
@@ -68,24 +67,27 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
"recently_played": recentlyPlayedFilter,
|
||||
"starred": booleanFilter,
|
||||
"has_rating": hasRatingFilter,
|
||||
}
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "COALESCE(NULLIF(sort_album_name,''),order_album_name)",
|
||||
"artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"albumArtist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"album_artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"random": "RANDOM()",
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "order_album_name asc, order_album_artist_name asc",
|
||||
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"albumArtist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"album_artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, order_album_name asc",
|
||||
"random": "RANDOM()",
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +182,7 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetAllWithoutGenres(options ...model.QueryOptions) (model.Albums, error) {
|
||||
r.resetSeededRandom(options)
|
||||
sq := r.selectAlbum(options...)
|
||||
var dba dbAlbums
|
||||
err := r.queryAll(sq, &dba)
|
||||
@@ -212,7 +215,7 @@ func (r *albumRepository) Search(q string, offset int, size int) (model.Albums,
|
||||
}
|
||||
|
||||
func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *albumRepository) Read(id string) (interface{}, error) {
|
||||
@@ -220,7 +223,7 @@ func (r *albumRepository) Read(id string) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *albumRepository) EntityName() string {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -20,7 +21,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe"})
|
||||
repo = NewAlbumRepository(ctx, getDBXBuilder())
|
||||
repo = NewAlbumRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
@@ -100,7 +101,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeAbsolute
|
||||
|
||||
id := uuid.NewString()
|
||||
Expect(repo.Put(&model.Album{ID: id, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
Expect(repo.Put(&model.Album{LibraryID: 1, ID: id, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
for i := 0; i < playCount; i++ {
|
||||
Expect(repo.IncPlayCount(id, time.Now())).To(Succeed())
|
||||
}
|
||||
@@ -123,7 +124,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeNormalized
|
||||
|
||||
id := uuid.NewString()
|
||||
Expect(repo.Put(&model.Album{ID: id, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
Expect(repo.Put(&model.Album{LibraryID: 1, ID: id, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
for i := 0; i < playCount; i++ {
|
||||
Expect(repo.IncPlayCount(id, time.Now())).To(Succeed())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
@@ -14,12 +15,12 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type artistRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
indexGroups utils.IndexGroups
|
||||
}
|
||||
|
||||
@@ -59,19 +60,22 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
|
||||
r.tableName = "artist"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
}
|
||||
r.tableName = "artist" // To be used by the idFilter below
|
||||
r.registerModel(&model.Artist{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name)",
|
||||
"name": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name)",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "order_artist_name",
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
}
|
||||
return r
|
||||
@@ -140,7 +144,11 @@ func (r *artistRepository) toModels(dba []dbArtist) model.Artists {
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
name := strings.ToLower(utils.NoArticle(a.Name))
|
||||
source := a.Name
|
||||
if conf.Server.PreferSortTags {
|
||||
source = cmp.Or(a.SortArtistName, a.OrderArtistName, source)
|
||||
}
|
||||
name := strings.ToLower(str.RemoveArticle(source))
|
||||
for k, v := range r.indexGroups {
|
||||
key := strings.ToLower(k)
|
||||
if strings.HasPrefix(name, key) {
|
||||
@@ -152,7 +160,11 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
all, err := r.GetAll(model.QueryOptions{Sort: "order_artist_name"})
|
||||
sortColumn := "order_artist_name"
|
||||
if conf.Server.PreferSortTags {
|
||||
sortColumn = "sort_artist_name, order_artist_name"
|
||||
}
|
||||
all, err := r.GetAll(model.QueryOptions{Sort: sortColumn})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -199,7 +211,7 @@ func (r *artistRepository) Search(q string, offset int, size int) (model.Artists
|
||||
}
|
||||
|
||||
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *artistRepository) Read(id string) (interface{}, error) {
|
||||
@@ -207,7 +219,7 @@ func (r *artistRepository) Read(id string) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *artistRepository) EntityName() string {
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/fatih/structs"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
. "github.com/onsi/gomega/gstruct"
|
||||
@@ -18,7 +21,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid"})
|
||||
repo = NewArtistRepository(ctx, getDBXBuilder())
|
||||
repo = NewArtistRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
@@ -42,8 +45,148 @@ var _ = Describe("ArtistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetIndexKey", func() {
|
||||
r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)}
|
||||
It("returns the index key when PreferSortTags is true and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a) // defines export_test.go
|
||||
Expect(idx).To(Equal("F"))
|
||||
|
||||
a = model.Artist{SortArtistName: "foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("F"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, SortArtistName is empty and OrderArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
|
||||
a = model.Artist{SortArtistName: "", OrderArtistName: "bar", Name: "Qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, both SortArtistName, OrderArtistName are empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
|
||||
a = model.Artist{SortArtistName: "", OrderArtistName: "", Name: "qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is false and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, SortArtistName is empty and OrderArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, both sort_artist_name, order_artist_name are empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
|
||||
a = model.Artist{SortArtistName: "", OrderArtistName: "", Name: "qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetIndex", func() {
|
||||
It("returns the index", func() {
|
||||
It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "F",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("returns the index when PreferSortTags is false and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the index when PreferSortTags is false and SortArtistName is empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
|
||||
22
persistence/dbx_builder.go
Normal file
22
persistence/dbx_builder.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type dbxBuilder struct {
|
||||
dbx.Builder
|
||||
wdb dbx.Builder
|
||||
}
|
||||
|
||||
func NewDBXBuilder(d db.DB) *dbxBuilder {
|
||||
b := &dbxBuilder{}
|
||||
b.Builder = dbx.NewFromDB(d.ReadDB(), db.Driver)
|
||||
b.wdb = dbx.NewFromDB(d.WriteDB(), db.Driver)
|
||||
return b
|
||||
}
|
||||
|
||||
func (d *dbxBuilder) Transactional(f func(*dbx.Tx) error) (err error) {
|
||||
return d.wdb.(*dbx.DB).Transactional(f)
|
||||
}
|
||||
5
persistence/export_test.go
Normal file
5
persistence/export_test.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package persistence
|
||||
|
||||
// Definitions for testing private methods
|
||||
|
||||
var GetIndexKey = (*artistRepository).getIndexKey
|
||||
@@ -14,17 +14,15 @@ import (
|
||||
|
||||
type genreRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository {
|
||||
r := &genreRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "genre"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": containsFilter,
|
||||
}
|
||||
r.registerModel(&model.Genre{}, map[string]filterFunc{
|
||||
"name": containsFilter("name"),
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -60,7 +58,7 @@ func (r *genreRepository) Put(m *model.Genre) error {
|
||||
}
|
||||
|
||||
func (r *genreRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), r.parseRestOptions(options...))
|
||||
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *genreRepository) Read(id string) (interface{}, error) {
|
||||
@@ -71,7 +69,7 @@ func (r *genreRepository) Read(id string) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(options...)).Columns("*")
|
||||
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
|
||||
res := model.Genres{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ = Describe("GenreRepository", func() {
|
||||
var repo model.GenreRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = persistence.NewGenreRepository(log.NewContext(context.TODO()), dbx.NewFromDB(db.Db(), db.Driver))
|
||||
repo = persistence.NewGenreRepository(log.NewContext(context.TODO()), persistence.NewDBXBuilder(db.Db()))
|
||||
})
|
||||
|
||||
Describe("GetAll()", func() {
|
||||
|
||||
83
persistence/library_repository.go
Normal file
83
persistence/library_repository.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type libraryRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewLibraryRepository(ctx context.Context, db dbx.Builder) model.LibraryRepository {
|
||||
r := &libraryRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.Library{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *libraryRepository) Get(id int) (*model.Library, error) {
|
||||
sq := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res model.Library
|
||||
err := r.queryOne(sq, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *libraryRepository) Put(l *model.Library) error {
|
||||
cols := map[string]any{
|
||||
"name": l.Name,
|
||||
"path": l.Path,
|
||||
"remote_path": l.RemotePath,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if l.ID != 0 {
|
||||
cols["id"] = l.ID
|
||||
}
|
||||
|
||||
sq := Insert(r.tableName).SetMap(cols).
|
||||
Suffix(`on conflict(id) do update set name = excluded.name, path = excluded.path,
|
||||
remote_path = excluded.remote_path, updated_at = excluded.updated_at`)
|
||||
_, err := r.executeSQL(sq)
|
||||
return err
|
||||
}
|
||||
|
||||
const hardCodedMusicFolderID = 1
|
||||
|
||||
// TODO Remove this method when we have a proper UI to add libraries
|
||||
func (r *libraryRepository) StoreMusicFolder() error {
|
||||
sq := Update(r.tableName).Set("path", conf.Server.MusicFolder).Set("updated_at", time.Now()).
|
||||
Where(Eq{"id": hardCodedMusicFolderID})
|
||||
_, err := r.executeSQL(sq)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *libraryRepository) AddArtist(id int, artistID string) error {
|
||||
sq := Insert("library_artist").Columns("library_id", "artist_id").Values(id, artistID).
|
||||
Suffix(`on conflict(library_id, artist_id) do nothing`)
|
||||
_, err := r.executeSQL(sq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *libraryRepository) UpdateLastScan(id int, t time.Time) error {
|
||||
sq := Update(r.tableName).Set("last_scan_at", t).Where(Eq{"id": id})
|
||||
_, err := r.executeSQL(sq)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) {
|
||||
sq := r.newSelect(ops...).Columns("*")
|
||||
res := model.Libraries{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
var _ model.LibraryRepository = (*libraryRepository)(nil)
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
|
||||
type mediaFileRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewMediaFileRepository(ctx context.Context, db dbx.Builder) *mediaFileRepository {
|
||||
@@ -26,26 +25,29 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) *mediaFileRepos
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "media_file"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"title": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
}
|
||||
r.registerModel(&model.MediaFile{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"title": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"title": "COALESCE(NULLIF(sort_title,''),title)",
|
||||
"artist": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc, COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_title,''),title) asc",
|
||||
"random": "RANDOM()",
|
||||
"createdAt": "media_file.created_at",
|
||||
"title": "COALESCE(NULLIF(sort_title,''),order_title)",
|
||||
"artist": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc, COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_title,''),title) asc",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
|
||||
"random": "RANDOM()",
|
||||
"createdAt": "media_file.created_at",
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
}
|
||||
return r
|
||||
@@ -102,6 +104,7 @@ func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
r.resetSeededRandom(options)
|
||||
sq := r.selectMediaFile(options...)
|
||||
res := model.MediaFiles{}
|
||||
err := r.queryAll(sq, &res, options...)
|
||||
@@ -124,6 +127,15 @@ func (r *mediaFileRepository) FindByPath(path string) (*model.MediaFile, error)
|
||||
return &res[0], nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
|
||||
var res model.MediaFiles
|
||||
if err := r.queryAll(sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func cleanPath(path string) string {
|
||||
path = filepath.Clean(path)
|
||||
if !strings.HasSuffix(path, string(os.PathSeparator)) {
|
||||
@@ -208,7 +220,7 @@ func (r *mediaFileRepository) Search(q string, offset int, size int) (model.Medi
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Read(id string) (interface{}, error) {
|
||||
@@ -216,7 +228,7 @@ func (r *mediaFileRepository) Read(id string) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) EntityName() string {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -19,7 +20,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid"})
|
||||
mr = NewMediaFileRepository(ctx, getDBXBuilder())
|
||||
mr = NewMediaFileRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
})
|
||||
|
||||
It("gets mediafile from the DB", func() {
|
||||
@@ -41,8 +42,8 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("finds tracks by path when using wildcards chars", func() {
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7001", Path: P("/Find:By'Path/_/123.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7002", Path: P("/Find:By'Path/1/123.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7001", Path: P("/Find:By'Path/_/123.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7002", Path: P("/Find:By'Path/1/123.mp3")})).To(BeNil())
|
||||
|
||||
found, err := mr.FindAllByPath(P("/Find:By'Path/_/"))
|
||||
Expect(err).To(BeNil())
|
||||
@@ -51,8 +52,8 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("finds tracks by path when using UTF8 chars", func() {
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7010", Path: P("/Пётр Ильич Чайковский/123.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7011", Path: P("/Пётр Ильич Чайковский/222.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7010", Path: P("/Пётр Ильич Чайковский/123.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7011", Path: P("/Пётр Ильич Чайковский/222.mp3")})).To(BeNil())
|
||||
|
||||
found, err := mr.FindAllByPath(P("/Пётр Ильич Чайковский/"))
|
||||
Expect(err).To(BeNil())
|
||||
@@ -60,8 +61,8 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("finds tracks by path case sensitively", func() {
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7003", Path: P("/Casesensitive/file1.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{ID: "7004", Path: P("/casesensitive/file2.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7003", Path: P("/Casesensitive/file1.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7004", Path: P("/casesensitive/file2.mp3")})).To(BeNil())
|
||||
|
||||
found, err := mr.FindAllByPath(P("/Casesensitive"))
|
||||
Expect(err).To(BeNil())
|
||||
@@ -76,7 +77,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("delete tracks by id", func() {
|
||||
id := uuid.NewString()
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
||||
|
||||
Expect(mr.Delete(id)).To(BeNil())
|
||||
|
||||
@@ -86,15 +87,15 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("delete tracks by path", func() {
|
||||
id1 := "6001"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "6002"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/abc/123/" + id2 + ".mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id2, Path: P("/abc/123/" + id2 + ".mp3")})).To(BeNil())
|
||||
id3 := "6003"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/ab_/" + id3 + ".mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id3, Path: P("/ab_/" + id3 + ".mp3")})).To(BeNil())
|
||||
id4 := "6004"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id4, Path: P("/abc/" + id4 + ".mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id4, Path: P("/abc/" + id4 + ".mp3")})).To(BeNil())
|
||||
id5 := "6005"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id5, Path: P("/Ab_/" + id5 + ".mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id5, Path: P("/Ab_/" + id5 + ".mp3")})).To(BeNil())
|
||||
|
||||
Expect(mr.DeleteByPath(P("/ab_"))).To(Equal(int64(1)))
|
||||
|
||||
@@ -108,11 +109,11 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("delete tracks by path containing UTF8 chars", func() {
|
||||
id1 := "6011"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/Legião Urbana/" + id1 + ".mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id1, Path: P("/Legião Urbana/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "6012"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/Legião Urbana/" + id2 + ".mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id2, Path: P("/Legião Urbana/" + id2 + ".mp3")})).To(BeNil())
|
||||
id3 := "6003"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/Legião Urbana/" + id3 + ".mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id3, Path: P("/Legião Urbana/" + id3 + ".mp3")})).To(BeNil())
|
||||
|
||||
Expect(mr.FindAllByPath(P("/Legião Urbana"))).To(HaveLen(3))
|
||||
Expect(mr.DeleteByPath(P("/Legião Urbana"))).To(Equal(int64(3)))
|
||||
@@ -121,11 +122,11 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("only deletes tracks that match exact path", func() {
|
||||
id1 := "6021"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/music/overlap/Ella Fitzgerald/" + id1 + ".mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id1, Path: P("/music/overlap/Ella Fitzgerald/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "6022"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/music/overlap/Ella Fitzgerald/" + id2 + ".mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id2, Path: P("/music/overlap/Ella Fitzgerald/" + id2 + ".mp3")})).To(BeNil())
|
||||
id3 := "6023"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/music/overlap/Ella Fitzgerald & Louis Armstrong - They Can't Take That Away From Me.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id3, Path: P("/music/overlap/Ella Fitzgerald & Louis Armstrong - They Can't Take That Away From Me.mp3")})).To(BeNil())
|
||||
|
||||
Expect(mr.FindAllByPath(P("/music/overlap/Ella Fitzgerald"))).To(HaveLen(2))
|
||||
Expect(mr.DeleteByPath(P("/music/overlap/Ella Fitzgerald"))).To(Equal(int64(2)))
|
||||
@@ -146,7 +147,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
Context("Annotations", func() {
|
||||
It("increments play count when the tracks does not have annotations", func() {
|
||||
id := "incplay.firsttime"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
||||
playDate := time.Now()
|
||||
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
||||
|
||||
@@ -159,7 +160,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("preserves play date if and only if provided date is older", func() {
|
||||
id := "incplay.playdate"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
||||
playDate := time.Now()
|
||||
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
||||
mf, err := mr.Get(id)
|
||||
@@ -184,7 +185,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("increments play count on newly starred items", func() {
|
||||
id := "star.incplay"
|
||||
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
||||
Expect(mr.SetStar(true, id)).To(BeNil())
|
||||
playDate := time.Now()
|
||||
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type mediaFolderRepository struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func NewMediaFolderRepository(ctx context.Context, _ dbx.Builder) model.MediaFolderRepository {
|
||||
return &mediaFolderRepository{ctx}
|
||||
}
|
||||
|
||||
func (r *mediaFolderRepository) Get(id int32) (*model.MediaFolder, error) {
|
||||
mediaFolder := hardCoded()
|
||||
return &mediaFolder, nil
|
||||
}
|
||||
|
||||
func (*mediaFolderRepository) GetAll() (model.MediaFolders, error) {
|
||||
mediaFolder := hardCoded()
|
||||
result := make(model.MediaFolders, 1)
|
||||
result[0] = mediaFolder
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hardCoded() model.MediaFolder {
|
||||
mediaFolder := model.MediaFolder{ID: 0, Path: conf.Server.MusicFolder}
|
||||
mediaFolder.Name = "Music Library"
|
||||
return mediaFolder
|
||||
}
|
||||
|
||||
var _ model.MediaFolderRepository = (*mediaFolderRepository)(nil)
|
||||
@@ -2,7 +2,6 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"reflect"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@@ -15,8 +14,8 @@ type SQLStore struct {
|
||||
db dbx.Builder
|
||||
}
|
||||
|
||||
func New(conn *sql.DB) model.DataStore {
|
||||
return &SQLStore{db: dbx.NewFromDB(conn, db.Driver)}
|
||||
func New(d db.DB) model.DataStore {
|
||||
return &SQLStore{db: NewDBXBuilder(d)}
|
||||
}
|
||||
|
||||
func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository {
|
||||
@@ -31,8 +30,8 @@ func (s *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
return NewMediaFileRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
|
||||
return NewMediaFolderRepository(ctx, s.getDBXBuilder())
|
||||
func (s *SQLStore) Library(ctx context.Context) model.LibraryRepository {
|
||||
return NewLibraryRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository {
|
||||
@@ -106,14 +105,18 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
|
||||
return nil
|
||||
}
|
||||
|
||||
type transactional interface {
|
||||
Transactional(f func(*dbx.Tx) error) (err error)
|
||||
}
|
||||
|
||||
func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
conn, ok := s.db.(*dbx.DB)
|
||||
if !ok {
|
||||
conn = dbx.NewFromDB(db.Db(), db.Driver)
|
||||
// If we are already in a transaction, just pass it down
|
||||
if conn, ok := s.db.(*dbx.Tx); ok {
|
||||
return block(&SQLStore{db: conn})
|
||||
}
|
||||
return conn.Transactional(func(tx *dbx.Tx) error {
|
||||
newDb := &SQLStore{db: tx}
|
||||
return block(newDb)
|
||||
|
||||
return s.db.(transactional).Transactional(func(tx *dbx.Tx) error {
|
||||
return block(&SQLStore{db: tx})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,7 +175,7 @@ func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
|
||||
|
||||
func (s *SQLStore) getDBXBuilder() dbx.Builder {
|
||||
if s.db == nil {
|
||||
return dbx.NewFromDB(db.Db(), db.Driver)
|
||||
return NewDBXBuilder(db.Db())
|
||||
}
|
||||
return s.db
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
func TestPersistence(t *testing.T) {
|
||||
@@ -22,17 +21,13 @@ func TestPersistence(t *testing.T) {
|
||||
|
||||
//os.Remove("./test-123.db")
|
||||
//conf.Server.DbPath = "./test-123.db"
|
||||
conf.Server.DbPath = "file::memory:?cache=shared"
|
||||
conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
|
||||
defer db.Init()()
|
||||
log.SetLevel(log.LevelError)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Persistence Suite")
|
||||
}
|
||||
|
||||
func getDBXBuilder() *dbx.DB {
|
||||
return dbx.NewFromDB(db.Db(), db.Driver)
|
||||
}
|
||||
|
||||
var (
|
||||
genreElectronic = model.Genre{ID: "gn-1", Name: "Electronic"}
|
||||
genreRock = model.Genre{ID: "gn-2", Name: "Rock"}
|
||||
@@ -40,8 +35,8 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"}
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles", AlbumCount: 2, FullText: " beatles the"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
@@ -49,9 +44,9 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
albumSgtPeppers = model.Album{ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: " beatles peppers sgt the", Discs: model.Discs{}}
|
||||
albumAbbeyRoad = model.Album{ID: "102", Name: "Abbey Road", Artist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: " abbey beatles road the", Discs: model.Discs{}}
|
||||
albumRadioactivity = model.Album{ID: "103", Name: "Radioactivity", Artist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, EmbedArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity", Discs: model.Discs{}}
|
||||
albumSgtPeppers = model.Album{LibraryID: 1, ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: " beatles peppers sgt the", Discs: model.Discs{}}
|
||||
albumAbbeyRoad = model.Album{LibraryID: 1, ID: "102", Name: "Abbey Road", Artist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: " abbey beatles road the", Discs: model.Discs{}}
|
||||
albumRadioactivity = model.Album{LibraryID: 1, ID: "103", Name: "Radioactivity", Artist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, EmbedArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity", Discs: model.Discs{}}
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
@@ -60,10 +55,10 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
songDayInALife = model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"}
|
||||
songComeTogether = model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"}
|
||||
songRadioactivity = model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Genres: model.Genres{genreElectronic}, Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"}
|
||||
songAntenna = model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
|
||||
songDayInALife = model.MediaFile{LibraryID: 1, ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"}
|
||||
songComeTogether = model.MediaFile{LibraryID: 1, ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"}
|
||||
songRadioactivity = model.MediaFile{LibraryID: 1, ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Genres: model.Genres{genreElectronic}, Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"}
|
||||
songAntenna = model.MediaFile{LibraryID: 1, ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
|
||||
AlbumID: "103", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock},
|
||||
Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk",
|
||||
RgAlbumGain: 1.0, RgAlbumPeak: 2.0, RgTrackGain: 3.0, RgTrackPeak: 4.0,
|
||||
@@ -88,6 +83,12 @@ var (
|
||||
testPlaylists []*model.Playlist
|
||||
)
|
||||
|
||||
var (
|
||||
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
|
||||
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
|
||||
testUsers = model.Users{adminUser, regularUser}
|
||||
)
|
||||
|
||||
func P(path string) string {
|
||||
return filepath.FromSlash(path)
|
||||
}
|
||||
@@ -95,15 +96,16 @@ func P(path string) string {
|
||||
// Initialize test DB
|
||||
// TODO Load this data setup from file(s)
|
||||
var _ = BeforeSuite(func() {
|
||||
conn := getDBXBuilder()
|
||||
conn := NewDBXBuilder(db.Db())
|
||||
ctx := log.NewContext(context.TODO())
|
||||
user := model.User{ID: "userid", UserName: "userid", IsAdmin: true}
|
||||
ctx = request.WithUser(ctx, user)
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
|
||||
ur := NewUserRepository(ctx, conn)
|
||||
err := ur.Put(&user)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
for i := range testUsers {
|
||||
err := ur.Put(&testUsers[i])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
gr := NewGenreRepository(ctx, conn)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -16,17 +15,13 @@ var _ = Describe("SQLStore", func() {
|
||||
BeforeEach(func() {
|
||||
ds = New(db.Db())
|
||||
ctx = context.Background()
|
||||
log.SetLevel(log.LevelFatal)
|
||||
})
|
||||
AfterEach(func() {
|
||||
log.SetLevel(log.LevelError)
|
||||
})
|
||||
Describe("WithTx", func() {
|
||||
Context("When block returns nil", func() {
|
||||
It("commits changes to the DB", func() {
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
pl := tx.Player(ctx)
|
||||
err := pl.Put(&model.Player{ID: "666", UserName: "userid"})
|
||||
err := pl.Put(&model.Player{ID: "666", UserId: "userid"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
pr := tx.Property(ctx)
|
||||
@@ -35,7 +30,7 @@ var _ = Describe("SQLStore", func() {
|
||||
return nil
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ds.Player(ctx).Get("666")).To(Equal(&model.Player{ID: "666", UserName: "userid"}))
|
||||
Expect(ds.Player(ctx).Get("666")).To(Equal(&model.Player{ID: "666", UserId: "userid", Username: "userid"}))
|
||||
Expect(ds.Property(ctx).Get("777")).To(Equal("value"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,16 +12,17 @@ import (
|
||||
|
||||
type playerRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerRepository {
|
||||
r := &playerRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "player"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": containsFilter,
|
||||
r.registerModel(&model.Player{}, map[string]filterFunc{
|
||||
"name": containsFilter("player.name"),
|
||||
})
|
||||
r.sortMappings = map[string]string{
|
||||
"user_name": "username", //TODO rename all user_name and userName to username
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -31,18 +32,25 @@ func (r *playerRepository) Put(p *model.Player) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *playerRepository) selectPlayer(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelect(options...).
|
||||
Columns("player.*").
|
||||
Join("user ON player.user_id = user.id").
|
||||
Columns("user.user_name username")
|
||||
}
|
||||
|
||||
func (r *playerRepository) Get(id string) (*model.Player, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
sel := r.selectPlayer().Where(Eq{"player.id": id})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) FindMatch(userName, client, userAgent string) (*model.Player, error) {
|
||||
sel := r.newSelect().Columns("*").Where(And{
|
||||
func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) {
|
||||
sel := r.selectPlayer().Where(And{
|
||||
Eq{"client": client},
|
||||
Eq{"user_agent": userAgent},
|
||||
Eq{"user_name": userName},
|
||||
Eq{"user_id": userId},
|
||||
})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
@@ -50,7 +58,7 @@ func (r *playerRepository) FindMatch(userName, client, userAgent string) (*model
|
||||
}
|
||||
|
||||
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
s := r.newSelect(options...)
|
||||
s := r.selectPlayer(options...)
|
||||
return s.Where(r.addRestriction())
|
||||
}
|
||||
|
||||
@@ -63,22 +71,22 @@ func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer {
|
||||
if u.IsAdmin {
|
||||
return s
|
||||
}
|
||||
return append(s, Eq{"user_name": u.UserName})
|
||||
return append(s, Eq{"user_id": u.ID})
|
||||
}
|
||||
|
||||
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(r.newRestSelect(), r.parseRestOptions(options...))
|
||||
return r.count(r.newRestSelect(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playerRepository) Read(id string) (interface{}, error) {
|
||||
sel := r.newRestSelect().Columns("*").Where(Eq{"id": id})
|
||||
sel := r.newRestSelect().Where(Eq{"player.id": id})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newRestSelect(r.parseRestOptions(options...)).Columns("*")
|
||||
sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...))
|
||||
res := model.Players{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
@@ -94,7 +102,7 @@ func (r *playerRepository) NewInstance() interface{} {
|
||||
|
||||
func (r *playerRepository) isPermitted(p *model.Player) bool {
|
||||
u := loggedUser(r.ctx)
|
||||
return u.IsAdmin || p.UserName == u.UserName
|
||||
return u.IsAdmin || p.UserId == u.ID
|
||||
}
|
||||
|
||||
func (r *playerRepository) Save(entity interface{}) (string, error) {
|
||||
@@ -123,7 +131,7 @@ func (r *playerRepository) Update(id string, entity interface{}, cols ...string)
|
||||
}
|
||||
|
||||
func (r *playerRepository) Delete(id string) error {
|
||||
filter := r.addRestriction(And{Eq{"id": id}})
|
||||
filter := r.addRestriction(And{Eq{"player.id": id}})
|
||||
err := r.delete(filter)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
|
||||
247
persistence/player_repository_test.go
Normal file
247
persistence/player_repository_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PlayerRepository", func() {
|
||||
var adminRepo *playerRepository
|
||||
var database *dbxBuilder
|
||||
|
||||
var (
|
||||
adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true}
|
||||
adminPlayer2 = model.Player{ID: "2", Name: "GenericClient [Chrome/Windows]", IP: "192.168.0.5", UserAgent: "Chrome/Windows", UserId: adminUser.ID, Username: adminUser.UserName, Client: "GenericClient", MaxBitRate: 128}
|
||||
regularPlayer = model.Player{ID: "3", Name: "NavidromeUI [Safari/macOS]", UserAgent: "Safari/macOS", UserId: regularUser.ID, Username: regularUser.UserName, Client: "NavidromeUI", ReportRealPath: true, ScrobbleEnabled: false}
|
||||
|
||||
players = model.Players{adminPlayer1, adminPlayer2, regularPlayer}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
|
||||
database = NewDBXBuilder(db.Db())
|
||||
adminRepo = NewPlayerRepository(ctx, database).(*playerRepository)
|
||||
|
||||
for idx := range players {
|
||||
err := adminRepo.Put(&players[idx])
|
||||
Expect(err).To(BeNil())
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
items, err := adminRepo.ReadAll()
|
||||
Expect(err).To(BeNil())
|
||||
players, ok := items.(model.Players)
|
||||
Expect(ok).To(BeTrue())
|
||||
for i := range players {
|
||||
err = adminRepo.Delete(players[i].ID)
|
||||
Expect(err).To(BeNil())
|
||||
}
|
||||
})
|
||||
|
||||
Describe("EntityName", func() {
|
||||
It("returns the right name", func() {
|
||||
Expect(adminRepo.EntityName()).To(Equal("player"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FindMatch", func() {
|
||||
It("finds existing match", func() {
|
||||
player, err := adminRepo.FindMatch(adminUser.ID, "NavidromeUI", "Firefox/Linux")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(*player).To(Equal(adminPlayer1))
|
||||
})
|
||||
|
||||
It("doesn't find bad match", func() {
|
||||
_, err := adminRepo.FindMatch(regularUser.ID, "NavidromeUI", "Firefox/Linux")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("Gets an existing item from user", func() {
|
||||
player, err := adminRepo.Get(adminPlayer1.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(*player).To(Equal(adminPlayer1))
|
||||
})
|
||||
|
||||
It("Gets an existing item from another user", func() {
|
||||
player, err := adminRepo.Get(regularPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(*player).To(Equal(regularPlayer))
|
||||
})
|
||||
|
||||
It("does not get nonexistent item", func() {
|
||||
_, err := adminRepo.Get("i don't exist")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) {
|
||||
var repo *playerRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
if admin {
|
||||
repo = adminRepo
|
||||
} else {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, regularUser)
|
||||
repo = NewPlayerRepository(ctx, database).(*playerRepository)
|
||||
}
|
||||
})
|
||||
|
||||
baseCount := int64(len(players))
|
||||
|
||||
Describe("Count", func() {
|
||||
It("should return all", func() {
|
||||
count, err := repo.Count()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(count).To(Equal(baseCount))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
DescribeTable("item type", func(player model.Player) {
|
||||
err := repo.Delete(player.ID)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
isReal := player.UserId != ""
|
||||
canDelete := admin || player.UserId == userPlayer.UserId
|
||||
|
||||
count, err := repo.Count()
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
if isReal && canDelete {
|
||||
Expect(count).To(Equal(baseCount - 1))
|
||||
} else {
|
||||
Expect(count).To(Equal(baseCount))
|
||||
}
|
||||
|
||||
item, err := repo.Get(player.ID)
|
||||
if !isReal || canDelete {
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
} else {
|
||||
Expect(*item).To(Equal(player))
|
||||
}
|
||||
},
|
||||
Entry("same user", userPlayer),
|
||||
Entry("other item", otherPlayer),
|
||||
Entry("fake item", model.Player{}),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Read", func() {
|
||||
It("can read from current user", func() {
|
||||
player, err := repo.Read(userPlayer.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(player).To(Equal(&userPlayer))
|
||||
})
|
||||
|
||||
It("can read from other user or fail if not admin", func() {
|
||||
player, err := repo.Read(otherPlayer.ID)
|
||||
if admin {
|
||||
Expect(err).To(BeNil())
|
||||
Expect(player).To(Equal(&otherPlayer))
|
||||
} else {
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
}
|
||||
})
|
||||
|
||||
It("does not get nonexistent item", func() {
|
||||
_, err := repo.Read("i don't exist")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReadAll", func() {
|
||||
It("should get all items", func() {
|
||||
data, err := repo.ReadAll()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(data).To(Equal(players))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Save", func() {
|
||||
DescribeTable("item type", func(player model.Player) {
|
||||
clone := player
|
||||
clone.ID = ""
|
||||
clone.IP = "192.168.1.1"
|
||||
id, err := repo.Save(&clone)
|
||||
|
||||
if clone.UserId == "" {
|
||||
Expect(err).To(HaveOccurred())
|
||||
} else if !admin && player.Username == adminPlayer1.Username {
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
clone.UserId = ""
|
||||
} else {
|
||||
Expect(err).To(BeNil())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
}
|
||||
|
||||
count, err := repo.Count()
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
clone.ID = id
|
||||
newItem, err := repo.Get(id)
|
||||
|
||||
if clone.UserId == "" {
|
||||
Expect(count).To(Equal(baseCount))
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
} else {
|
||||
Expect(count).To(Equal(baseCount + 1))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(*newItem).To(Equal(clone))
|
||||
}
|
||||
},
|
||||
Entry("same user", userPlayer),
|
||||
Entry("other item", otherPlayer),
|
||||
Entry("fake item", model.Player{}),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
DescribeTable("item type", func(player model.Player) {
|
||||
clone := player
|
||||
clone.IP = "192.168.1.1"
|
||||
clone.MaxBitRate = 10000
|
||||
err := repo.Update(clone.ID, &clone, "ip")
|
||||
|
||||
if clone.UserId == "" {
|
||||
Expect(err).To(HaveOccurred())
|
||||
} else if !admin && player.Username == adminPlayer1.Username {
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
clone.IP = player.IP
|
||||
} else {
|
||||
Expect(err).To(BeNil())
|
||||
}
|
||||
|
||||
clone.MaxBitRate = player.MaxBitRate
|
||||
newItem, err := repo.Get(clone.ID)
|
||||
|
||||
if player.UserId == "" {
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
} else if !admin && player.UserId == adminUser.ID {
|
||||
Expect(*newItem).To(Equal(player))
|
||||
} else {
|
||||
Expect(*newItem).To(Equal(clone))
|
||||
}
|
||||
},
|
||||
Entry("same user", userPlayer),
|
||||
Entry("other item", otherPlayer),
|
||||
Entry("fake item", model.Player{}),
|
||||
)
|
||||
})
|
||||
},
|
||||
Entry("admin context", true, players, adminPlayer1, regularPlayer),
|
||||
Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1),
|
||||
)
|
||||
})
|
||||
@@ -5,20 +5,21 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type playlistRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
type dbPlaylist struct {
|
||||
@@ -37,7 +38,10 @@ func (p dbPlaylist) PostMapArgs(args map[string]any) error {
|
||||
var err error
|
||||
if p.Playlist.IsSmartPlaylist() {
|
||||
args["rules"], err = json.Marshal(p.Playlist.Rules)
|
||||
return err
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid criteria expression: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
delete(args, "rules")
|
||||
return nil
|
||||
@@ -47,10 +51,12 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
||||
r := &playlistRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "playlist"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
r.registerModel(&model.Playlist{}, map[string]filterFunc{
|
||||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
})
|
||||
r.sortMappings = map[string]string{
|
||||
"owner_name": "owner_name",
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -194,8 +200,8 @@ func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) Selec
|
||||
}
|
||||
|
||||
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
// Only refresh if it is a smart playlist and was not refreshed in the last 5 seconds
|
||||
if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < 5*time.Second) {
|
||||
// Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config
|
||||
if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -219,6 +225,18 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
|
||||
// Re-populate playlist based on Smart Playlist criteria
|
||||
rules := *pls.Rules
|
||||
|
||||
// If the playlist depends on other playlists, recursively refresh them first
|
||||
childPlaylistIds := rules.ChildPlaylistIds()
|
||||
for _, id := range childPlaylistIds {
|
||||
childPls, err := r.Get(id)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err)
|
||||
return false
|
||||
}
|
||||
r.refreshSmartPlaylist(childPls)
|
||||
}
|
||||
|
||||
sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
|
||||
From("media_file").LeftJoin("annotation on (" +
|
||||
"annotation.item_id = media_file.id" +
|
||||
@@ -289,14 +307,12 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st
|
||||
}
|
||||
|
||||
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error {
|
||||
// Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
chunks := slice.BreakUp(mediaFileIds, 200)
|
||||
|
||||
// Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
|
||||
// Add new tracks, chunk by chunk
|
||||
pos := startingPos
|
||||
for i := range chunks {
|
||||
for chunk := range slices.Chunk(mediaFileIds, 200) {
|
||||
ins := Insert("playlist_tracks").Columns("playlist_id", "media_file_id", "id")
|
||||
for _, t := range chunks[i] {
|
||||
for _, t := range chunk {
|
||||
ins = ins.Values(playlistId, t, pos)
|
||||
pos++
|
||||
}
|
||||
@@ -368,7 +384,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Read(id string) (interface{}, error) {
|
||||
@@ -376,7 +392,7 @@ func (r *playlistRepository) Read(id string) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) EntityName() string {
|
||||
|
||||
@@ -2,7 +2,10 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
@@ -17,7 +20,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPlaylistRepository(ctx, getDBXBuilder())
|
||||
repo = NewPlaylistRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
@@ -119,13 +122,86 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
},
|
||||
}
|
||||
})
|
||||
It("Put/Get", func() {
|
||||
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
Context("valid rules", func() {
|
||||
Specify("Put/Get", func() {
|
||||
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
|
||||
savedPls, err := repo.Get(newPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(savedPls.Rules).To(Equal(rules))
|
||||
savedPls, err := repo.Get(newPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(savedPls.Rules).To(Equal(rules))
|
||||
})
|
||||
})
|
||||
|
||||
Context("invalid rules", func() {
|
||||
It("fails to Put it in the DB", func() {
|
||||
rules = &criteria.Criteria{
|
||||
// This is invalid because "contains" cannot have multiple fields
|
||||
Expression: criteria.All{
|
||||
criteria.Contains{"genre": "Hardcore", "filetype": "mp3"},
|
||||
},
|
||||
}
|
||||
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression")))
|
||||
})
|
||||
})
|
||||
|
||||
Context("child smart playlists", func() {
|
||||
When("refresh day has expired", func() {
|
||||
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&nestedPls)).To(Succeed())
|
||||
|
||||
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.InPlaylist{"id": nestedPls.ID},
|
||||
},
|
||||
}}
|
||||
Expect(repo.Put(&parentPls)).To(Succeed())
|
||||
|
||||
nestedPlsRead, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = repo.GetWithTracks(parentPls.ID, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get
|
||||
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt))
|
||||
})
|
||||
})
|
||||
|
||||
When("refresh day has not expired", func() {
|
||||
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
|
||||
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&nestedPls)).To(Succeed())
|
||||
|
||||
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.InPlaylist{"id": nestedPls.ID},
|
||||
},
|
||||
}}
|
||||
Expect(repo.Put(&parentPls)).To(Succeed())
|
||||
|
||||
nestedPlsRead, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = repo.GetWithTracks(parentPls.ID, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get
|
||||
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
type playlistTrackRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
playlistId string
|
||||
playlist *model.Playlist
|
||||
playlistRepo *playlistRepository
|
||||
@@ -26,11 +25,13 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
p.ctx = r.ctx
|
||||
p.db = r.db
|
||||
p.tableName = "playlist_tracks"
|
||||
p.registerModel(&model.PlaylistTrack{}, nil)
|
||||
p.sortMappings = map[string]string{
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name asc",
|
||||
"album": "order_album_name asc, order_album_artist_name asc",
|
||||
"title": "order_title",
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name asc",
|
||||
"album": "order_album_name asc, order_album_artist_name asc",
|
||||
"title": "order_title",
|
||||
"duration": "duration", // To make sure the field will be whitelisted
|
||||
}
|
||||
if conf.Server.PreferSortTags {
|
||||
p.sortMappings["artist"] = "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc"
|
||||
@@ -51,7 +52,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(options...))
|
||||
return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
||||
@@ -112,7 +113,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) EntityName() string {
|
||||
|
||||
@@ -101,25 +101,22 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
|
||||
return q
|
||||
}
|
||||
|
||||
// loadTracks loads the tracks from the database. It receives a list of track IDs and returns a list of MediaFiles
|
||||
// in the same order as the input list.
|
||||
func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFiles {
|
||||
if len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect all ids
|
||||
ids := make([]string, len(tracks))
|
||||
for i, t := range tracks {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
|
||||
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
chunks := slice.BreakUp(ids, 50)
|
||||
|
||||
// Query each chunk of media_file ids and store results in a map
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.db)
|
||||
trackMap := map[string]model.MediaFile{}
|
||||
for i := range chunks {
|
||||
idsFilter := Eq{"media_file.id": chunks[i]}
|
||||
|
||||
// Create an iterator to collect all track IDs
|
||||
ids := slice.SeqFunc(tracks, func(t model.MediaFile) string { return t.ID })
|
||||
|
||||
// Break the list in chunks, up to 500 items, to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
|
||||
for chunk := range slice.CollectChunks(ids, 500) {
|
||||
idsFilter := Eq{"media_file.id": chunk}
|
||||
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
|
||||
if err != nil {
|
||||
u := loggedUser(r.ctx)
|
||||
@@ -131,9 +128,12 @@ func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFil
|
||||
}
|
||||
|
||||
// Create a new list of tracks with the same order as the original
|
||||
newTracks := make(model.MediaFiles, len(tracks))
|
||||
for i, t := range tracks {
|
||||
newTracks[i] = trackMap[t.ID]
|
||||
// Exclude tracks that are not in the DB anymore
|
||||
newTracks := make(model.MediaFiles, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
if track, ok := trackMap[t.ID]; ok {
|
||||
newTracks = append(newTracks, track)
|
||||
}
|
||||
}
|
||||
return newTracks
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -15,11 +16,12 @@ import (
|
||||
|
||||
var _ = Describe("PlayQueueRepository", func() {
|
||||
var repo model.PlayQueueRepository
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPlayQueueRepository(ctx, getDBXBuilder())
|
||||
repo = NewPlayQueueRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
})
|
||||
|
||||
Describe("PlayQueues", func() {
|
||||
@@ -50,6 +52,37 @@ var _ = Describe("PlayQueueRepository", func() {
|
||||
AssertPlayQueue(another, actual)
|
||||
Expect(countPlayQueues(repo, "userid")).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not return tracks if they don't exist in the DB", func() {
|
||||
// Add a new song to the DB
|
||||
newSong := songRadioactivity
|
||||
newSong.ID = "temp-track"
|
||||
mfRepo := NewMediaFileRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
|
||||
Expect(mfRepo.Put(&newSong)).To(Succeed())
|
||||
|
||||
// Create a playqueue with the new song
|
||||
pq := aPlayQueue("userid", newSong.ID, 0, newSong, songAntenna)
|
||||
Expect(repo.Store(pq)).To(Succeed())
|
||||
|
||||
// Retrieve the playqueue
|
||||
actual, err := repo.Retrieve("userid")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The playqueue should contain both tracks
|
||||
AssertPlayQueue(pq, actual)
|
||||
|
||||
// Delete the new song
|
||||
Expect(mfRepo.Delete("temp-track")).To(Succeed())
|
||||
|
||||
// Retrieve the playqueue
|
||||
actual, err = repo.Retrieve("userid")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The playqueue should not contain the deleted track
|
||||
Expect(actual.Items).To(HaveLen(1))
|
||||
Expect(actual.Items[0].ID).To(Equal(songAntenna.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -13,7 +14,7 @@ var _ = Describe("Property Repository", func() {
|
||||
var pr model.PropertyRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
pr = NewPropertyRepository(log.NewContext(context.TODO()), getDBXBuilder())
|
||||
pr = NewPropertyRepository(log.NewContext(context.TODO()), NewDBXBuilder(db.Db()))
|
||||
})
|
||||
|
||||
It("saves and restore a new property", func() {
|
||||
|
||||
@@ -15,17 +15,15 @@ import (
|
||||
|
||||
type radioRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewRadioRepository(ctx context.Context, db dbx.Builder) model.RadioRepository {
|
||||
r := &radioRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "radio"
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": containsFilter,
|
||||
}
|
||||
r.registerModel(&model.Radio{}, map[string]filterFunc{
|
||||
"name": containsFilter("name"),
|
||||
})
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "(name collate nocase), name",
|
||||
}
|
||||
@@ -96,7 +94,7 @@ func (r *radioRepository) Put(radio *model.Radio) error {
|
||||
}
|
||||
|
||||
func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *radioRepository) EntityName() string {
|
||||
@@ -112,7 +110,7 @@ func (r *radioRepository) Read(id string) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *radioRepository) Save(entity interface{}) (string, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -22,7 +23,7 @@ var _ = Describe("RadioRepository", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewRadioRepository(ctx, getDBXBuilder())
|
||||
repo = NewRadioRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
_ = repo.Put(&radioWithHomePage)
|
||||
})
|
||||
|
||||
@@ -119,7 +120,7 @@ var _ = Describe("RadioRepository", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false})
|
||||
repo = NewRadioRepository(ctx, getDBXBuilder())
|
||||
repo = NewRadioRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
|
||||
@@ -17,14 +17,16 @@ import (
|
||||
|
||||
type shareRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareRepository {
|
||||
r := &shareRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "share"
|
||||
r.registerModel(&model.Share{}, map[string]filterFunc{})
|
||||
r.sortMappings = map[string]string{
|
||||
"username": "username",
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -166,7 +168,7 @@ func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error)
|
||||
}
|
||||
|
||||
func (r *shareRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *shareRepository) EntityName() string {
|
||||
@@ -185,7 +187,7 @@ func (r *shareRepository) Read(id string) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sq := r.selectShare(r.parseRestOptions(options...))
|
||||
sq := r.selectShare(r.parseRestOptions(r.ctx, options...))
|
||||
res := model.Shares{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -52,7 +51,6 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
|
||||
c, err := r.executeSQL(upd)
|
||||
if c == 0 || errors.Is(err, sql.ErrNoRows) {
|
||||
for _, itemID := range itemIDs {
|
||||
values["ann_id"] = uuid.NewString()
|
||||
values["user_id"] = userId(r.ctx)
|
||||
values["item_type"] = r.tableName
|
||||
values["item_id"] = itemID
|
||||
@@ -83,7 +81,6 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||
|
||||
if c == 0 || errors.Is(err, sql.ErrNoRows) {
|
||||
values := map[string]interface{}{}
|
||||
values["ann_id"] = uuid.NewString()
|
||||
values["user_id"] = userId(r.ctx)
|
||||
values["item_type"] = r.tableName
|
||||
values["item_id"] = itemID
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,14 +15,29 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/hasher"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// sqlRepository is the base repository for all SQL repositories. It provides common functions to interact with the DB.
|
||||
// When creating a new repository using this base, you must:
|
||||
//
|
||||
// - Embed this struct.
|
||||
// - Set ctx and db fields. ctx should be the context passed to the constructor method, usually obtained from the request
|
||||
// - Call registerModel with the model instance and any possible filters.
|
||||
// - If the model has a different table name than the default (lowercase of the model name), it should be set manually
|
||||
// using the tableName field.
|
||||
// - Sort mappings should be set in the sortMappings field. If the sort field is not in the map, it will be used as is.
|
||||
//
|
||||
// All fields in filters and sortMappings must be in snake_case. Only sorts and filters based on real field names or
|
||||
// defined in the mappings will be allowed.
|
||||
type sqlRepository struct {
|
||||
ctx context.Context
|
||||
tableName string
|
||||
db dbx.Builder
|
||||
sortMappings map[string]string
|
||||
ctx context.Context
|
||||
tableName string
|
||||
db dbx.Builder
|
||||
sortMappings map[string]string
|
||||
filterMappings map[string]filterFunc
|
||||
isFieldWhiteListed fieldWhiteListedFunc
|
||||
}
|
||||
|
||||
const invalidUserId = "-1"
|
||||
@@ -42,6 +58,16 @@ func loggedUser(ctx context.Context) *model.User {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
||||
if r.tableName == "" {
|
||||
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
||||
r.tableName = toSnakeCase(r.tableName)
|
||||
}
|
||||
r.tableName = strings.ToLower(r.tableName)
|
||||
r.isFieldWhiteListed = registerModelWhiteList(instance)
|
||||
r.filterMappings = filters
|
||||
}
|
||||
|
||||
func (r sqlRepository) getTableName() string {
|
||||
return r.tableName
|
||||
}
|
||||
@@ -137,6 +163,24 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti
|
||||
return sq
|
||||
}
|
||||
|
||||
func (r sqlRepository) seedKey() string {
|
||||
return r.tableName + userId(r.ctx)
|
||||
}
|
||||
|
||||
func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) {
|
||||
if len(options) == 0 || options[0].Sort != "random" {
|
||||
return
|
||||
}
|
||||
options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName)
|
||||
if options[0].Seed != "" {
|
||||
hasher.SetSeed(r.seedKey(), options[0].Seed)
|
||||
return
|
||||
}
|
||||
if options[0].Offset == 0 {
|
||||
hasher.Reseed(r.seedKey())
|
||||
}
|
||||
}
|
||||
|
||||
func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
|
||||
query, args, err := r.toSQL(sq)
|
||||
if err != nil {
|
||||
@@ -201,7 +245,7 @@ func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options
|
||||
r.logSQL(query, args, nil, -1, start)
|
||||
return model.ErrNotFound
|
||||
}
|
||||
r.logSQL(query, args, err, -1, start)
|
||||
r.logSQL(query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -217,7 +261,7 @@ func (r sqlRepository) queryAllSlice(sq SelectBuilder, response interface{}) err
|
||||
r.logSQL(query, args, nil, -1, start)
|
||||
return model.ErrNotFound
|
||||
}
|
||||
r.logSQL(query, args, err, -1, start)
|
||||
r.logSQL(query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -253,7 +297,10 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
|
||||
}
|
||||
|
||||
func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) {
|
||||
values, _ := toSQLArgs(m)
|
||||
values, err := toSQLArgs(m)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error preparing values to write to DB: %w", err)
|
||||
}
|
||||
// If there's an ID, try to update first
|
||||
if id != "" {
|
||||
updateValues := map[string]interface{}{}
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/hasher"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sqlRepository", func() {
|
||||
r := sqlRepository{}
|
||||
var r sqlRepository
|
||||
BeforeEach(func() {
|
||||
r.ctx = request.WithUser(context.Background(), model.User{ID: "user-id"})
|
||||
r.tableName = "table"
|
||||
})
|
||||
|
||||
Describe("applyOptions", func() {
|
||||
var sq squirrel.SelectBuilder
|
||||
BeforeEach(func() {
|
||||
sq = squirrel.Select("*").From("test")
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "title",
|
||||
}
|
||||
})
|
||||
It("does not add any clauses when options is empty", func() {
|
||||
sq = r.applyOptions(sq, model.QueryOptions{})
|
||||
@@ -27,17 +39,11 @@ var _ = Describe("sqlRepository", func() {
|
||||
Offset: 2,
|
||||
})
|
||||
sql, _, _ := sq.ToSql()
|
||||
Expect(sql).To(Equal("SELECT * FROM test ORDER BY name desc LIMIT 1 OFFSET 2"))
|
||||
Expect(sql).To(Equal("SELECT * FROM test ORDER BY title desc LIMIT 1 OFFSET 2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("toSQL", func() {
|
||||
var r sqlRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
r = sqlRepository{}
|
||||
})
|
||||
|
||||
It("returns error for invalid SQL", func() {
|
||||
sq := squirrel.Select("*").From("test").Where(1)
|
||||
_, _, err := r.toSQL(sq)
|
||||
@@ -70,40 +76,61 @@ var _ = Describe("sqlRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sortMapping", func() {
|
||||
Describe("sanitizeSort", func() {
|
||||
BeforeEach(func() {
|
||||
r.registerModel(&struct {
|
||||
Field string `structs:"field"`
|
||||
}{}, nil)
|
||||
r.sortMappings = map[string]string{
|
||||
"sort1": "mappedSort1",
|
||||
"sortTwo": "mappedSort2",
|
||||
"sort_three": "mappedSort3",
|
||||
"sort1": "mappedSort1",
|
||||
}
|
||||
})
|
||||
|
||||
It("returns the mapped value when sort key exists", func() {
|
||||
Expect(r.sortMapping("sort1")).To(Equal("mappedSort1"))
|
||||
})
|
||||
When("sanitizing sort", func() {
|
||||
It("returns empty if the sort key is not found in the model nor in the mappings", func() {
|
||||
sort, _ := r.sanitizeSort("unknown", "")
|
||||
Expect(sort).To(BeEmpty())
|
||||
})
|
||||
|
||||
Context("when sort key does not exist", func() {
|
||||
It("returns the original sort key, snake cased", func() {
|
||||
Expect(r.sortMapping("NotFoundSort")).To(Equal("not_found_sort"))
|
||||
It("returns the mapped value when sort key exists", func() {
|
||||
sort, _ := r.sanitizeSort("sort1", "")
|
||||
Expect(sort).To(Equal("mappedSort1"))
|
||||
})
|
||||
|
||||
It("is case insensitive", func() {
|
||||
sort, _ := r.sanitizeSort("Sort1", "")
|
||||
Expect(sort).To(Equal("mappedSort1"))
|
||||
})
|
||||
|
||||
It("returns the field if it is a valid field", func() {
|
||||
sort, _ := r.sanitizeSort("field", "")
|
||||
Expect(sort).To(Equal("field"))
|
||||
})
|
||||
|
||||
It("is case insensitive for fields", func() {
|
||||
sort, _ := r.sanitizeSort("FIELD", "")
|
||||
Expect(sort).To(Equal("field"))
|
||||
})
|
||||
})
|
||||
When("sanitizing order", func() {
|
||||
It("returns 'asc' if order is empty", func() {
|
||||
_, order := r.sanitizeSort("", "")
|
||||
Expect(order).To(Equal(""))
|
||||
})
|
||||
|
||||
Context("when sort key is camel cased", func() {
|
||||
It("returns the mapped value when camel case sort key exists", func() {
|
||||
Expect(r.sortMapping("sortTwo")).To(Equal("mappedSort2"))
|
||||
It("returns 'asc' if order is 'asc'", func() {
|
||||
_, order := r.sanitizeSort("", "ASC")
|
||||
Expect(order).To(Equal("asc"))
|
||||
})
|
||||
It("returns the mapped value when passing a snake case key", func() {
|
||||
Expect(r.sortMapping("sort_two")).To(Equal("mappedSort2"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when sort key is snake cased", func() {
|
||||
It("returns the mapped value when snake case sort key exists", func() {
|
||||
Expect(r.sortMapping("sort_three")).To(Equal("mappedSort3"))
|
||||
It("returns 'desc' if order is 'desc'", func() {
|
||||
_, order := r.sanitizeSort("", "desc")
|
||||
Expect(order).To(Equal("desc"))
|
||||
})
|
||||
It("returns the mapped value when passing a camel case key", func() {
|
||||
Expect(r.sortMapping("sortThree")).To(Equal("mappedSort3"))
|
||||
|
||||
It("returns 'asc' if order is unknown", func() {
|
||||
_, order := r.sanitizeSort("", "something")
|
||||
Expect(order).To(Equal("asc"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -152,4 +179,36 @@ var _ = Describe("sqlRepository", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resetSeededRandom", func() {
|
||||
var id string
|
||||
BeforeEach(func() {
|
||||
id = r.seedKey()
|
||||
hasher.SetSeed(id, "")
|
||||
})
|
||||
It("does not reset seed if sort is not random", func() {
|
||||
var options []model.QueryOptions
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(BeEmpty())
|
||||
})
|
||||
It("resets seed if sort is random", func() {
|
||||
options := []model.QueryOptions{{Sort: "random"}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).NotTo(BeEmpty())
|
||||
})
|
||||
It("resets seed if sort is random and seed is provided", func() {
|
||||
options := []model.QueryOptions{{Sort: "random", Seed: "seed"}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
|
||||
})
|
||||
It("keeps seed when paginating", func() {
|
||||
options := []model.QueryOptions{{Sort: "random", Seed: "seed", Offset: 0}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
|
||||
|
||||
options = []model.QueryOptions{{Sort: "random", Offset: 1}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -16,7 +17,7 @@ var _ = Describe("sqlBookmarks", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid"})
|
||||
mr = NewMediaFileRepository(ctx, getDBXBuilder())
|
||||
mr = NewMediaFileRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
})
|
||||
|
||||
Describe("Bookmarks", func() {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (r sqlRepository) withGenres(sql SelectBuilder) SelectBuilder {
|
||||
@@ -22,19 +23,17 @@ func (r *sqlRepository) updateGenres(id string, genres model.Genres) error {
|
||||
if len(genres) == 0 {
|
||||
return nil
|
||||
}
|
||||
var genreIds []string
|
||||
for _, g := range genres {
|
||||
genreIds = append(genreIds, g.ID)
|
||||
}
|
||||
err = slice.RangeByChunks(genreIds, 100, func(ids []string) error {
|
||||
|
||||
for chunk := range slices.Chunk(genres, 100) {
|
||||
ins := Insert(tableName+"_genres").Columns("genre_id", tableName+"_id")
|
||||
for _, gid := range ids {
|
||||
ins = ins.Values(gid, id)
|
||||
for _, genre := range chunk {
|
||||
ins = ins.Values(genre.ID, id)
|
||||
}
|
||||
_, err = r.executeSQL(ins)
|
||||
return err
|
||||
})
|
||||
return err
|
||||
if _, err = r.executeSQL(ins); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type baseRepository interface {
|
||||
@@ -71,24 +70,24 @@ func appendGenre[T modelWithGenres](item *T, genre model.Genre) {
|
||||
|
||||
func loadGenres[T modelWithGenres](r baseRepository, ids []string, items map[string]*T) error {
|
||||
tableName := r.getTableName()
|
||||
return slice.RangeByChunks(ids, 900, func(ids []string) error {
|
||||
|
||||
for chunk := range slices.Chunk(ids, 900) {
|
||||
sql := Select("genre.*", tableName+"_id as item_id").From("genre").
|
||||
Join(tableName+"_genres ig on genre.id = ig.genre_id").
|
||||
OrderBy(tableName+"_id", "ig.rowid").Where(Eq{tableName + "_id": ids})
|
||||
OrderBy(tableName+"_id", "ig.rowid").Where(Eq{tableName + "_id": chunk})
|
||||
|
||||
var genres []struct {
|
||||
model.Genre
|
||||
ItemID string
|
||||
}
|
||||
err := r.queryAll(sql, &genres)
|
||||
if err != nil {
|
||||
if err := r.queryAll(sql, &genres); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, g := range genres {
|
||||
appendGenre(items[g.ItemID], g.Genre)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAllGenres[T modelWithGenres](r baseRepository, items []T) error {
|
||||
|
||||
@@ -1,74 +1,113 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/fatih/structs"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type filterFunc = func(field string, value interface{}) Sqlizer
|
||||
type filterFunc = func(field string, value any) Sqlizer
|
||||
|
||||
type sqlRestful struct {
|
||||
filterMappings map[string]filterFunc
|
||||
}
|
||||
|
||||
func (r sqlRestful) parseRestFilters(options rest.QueryOptions) Sqlizer {
|
||||
func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.QueryOptions) Sqlizer {
|
||||
if len(options.Filters) == 0 {
|
||||
return nil
|
||||
}
|
||||
filters := And{}
|
||||
for f, v := range options.Filters {
|
||||
// Ignore filters with empty values
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
// Look for a custom filter function
|
||||
f = strings.ToLower(f)
|
||||
if ff, ok := r.filterMappings[f]; ok {
|
||||
filters = append(filters, ff(f, v))
|
||||
} else if strings.HasSuffix(strings.ToLower(f), "id") {
|
||||
filters = append(filters, eqFilter(f, v))
|
||||
} else {
|
||||
filters = append(filters, startsWithFilter(f, v))
|
||||
continue
|
||||
}
|
||||
// Ignore invalid filters (not based on a field or filter function)
|
||||
if r.isFieldWhiteListed != nil && !r.isFieldWhiteListed(f) {
|
||||
log.Warn(ctx, "Ignoring filter not whitelisted", "filter", f)
|
||||
continue
|
||||
}
|
||||
// For fields ending in "id", use an exact match
|
||||
if strings.HasSuffix(f, "id") {
|
||||
filters = append(filters, eqFilter(f, v))
|
||||
continue
|
||||
}
|
||||
// Default to a "starts with" filter
|
||||
filters = append(filters, startsWithFilter(f, v))
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
func (r sqlRestful) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions {
|
||||
func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.QueryOptions) model.QueryOptions {
|
||||
qo := model.QueryOptions{}
|
||||
if len(options) > 0 {
|
||||
qo.Sort = options[0].Sort
|
||||
qo.Order = strings.ToLower(options[0].Order)
|
||||
qo.Sort, qo.Order = r.sanitizeSort(options[0].Sort, options[0].Order)
|
||||
qo.Max = options[0].Max
|
||||
qo.Offset = options[0].Offset
|
||||
qo.Filters = r.parseRestFilters(options[0])
|
||||
if seed, ok := options[0].Filters["seed"].(string); ok {
|
||||
qo.Seed = seed
|
||||
delete(options[0].Filters, "seed")
|
||||
}
|
||||
qo.Filters = r.parseRestFilters(ctx, options[0])
|
||||
}
|
||||
return qo
|
||||
}
|
||||
|
||||
func eqFilter(field string, value interface{}) Sqlizer {
|
||||
func (r sqlRepository) sanitizeSort(sort, order string) (string, string) {
|
||||
if sort != "" {
|
||||
sort = toSnakeCase(sort)
|
||||
if mapped, ok := r.sortMappings[sort]; ok {
|
||||
sort = mapped
|
||||
} else {
|
||||
if !r.isFieldWhiteListed(sort) {
|
||||
log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort)
|
||||
sort = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if order != "" {
|
||||
order = strings.ToLower(order)
|
||||
if order != "desc" {
|
||||
order = "asc"
|
||||
}
|
||||
}
|
||||
return sort, order
|
||||
}
|
||||
|
||||
func eqFilter(field string, value any) Sqlizer {
|
||||
return Eq{field: value}
|
||||
}
|
||||
|
||||
func startsWithFilter(field string, value interface{}) Sqlizer {
|
||||
func startsWithFilter(field string, value any) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%s%%", value)}
|
||||
}
|
||||
|
||||
func containsFilter(field string, value interface{}) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%%%s%%", value)}
|
||||
func containsFilter(field string) func(string, any) Sqlizer {
|
||||
return func(_ string, value any) Sqlizer {
|
||||
return Like{field: fmt.Sprintf("%%%s%%", value)}
|
||||
}
|
||||
}
|
||||
|
||||
func booleanFilter(field string, value interface{}) Sqlizer {
|
||||
func booleanFilter(field string, value any) Sqlizer {
|
||||
v := strings.ToLower(value.(string))
|
||||
return Eq{field: strings.ToLower(v) == "true"}
|
||||
}
|
||||
|
||||
func fullTextFilter(field string, value interface{}) Sqlizer {
|
||||
func fullTextFilter(_ string, value any) Sqlizer {
|
||||
return fullTextExpr(value.(string))
|
||||
}
|
||||
|
||||
func substringFilter(field string, value interface{}) Sqlizer {
|
||||
func substringFilter(field string, value any) Sqlizer {
|
||||
parts := strings.Split(value.(string), " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
@@ -77,8 +116,57 @@ func substringFilter(field string, value interface{}) Sqlizer {
|
||||
return filters
|
||||
}
|
||||
|
||||
func idFilter(tableName string) func(string, interface{}) Sqlizer {
|
||||
return func(field string, value interface{}) Sqlizer {
|
||||
func idFilter(tableName string) func(string, any) Sqlizer {
|
||||
return func(field string, value any) Sqlizer {
|
||||
return Eq{tableName + ".id": value}
|
||||
}
|
||||
}
|
||||
|
||||
func invalidFilter(ctx context.Context) func(string, any) Sqlizer {
|
||||
return func(field string, value any) Sqlizer {
|
||||
log.Warn(ctx, "Invalid filter", "fieldName", field, "value", value)
|
||||
return Eq{"1": "0"}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
whiteList = map[string]map[string]struct{}{}
|
||||
mutex sync.RWMutex
|
||||
)
|
||||
|
||||
func registerModelWhiteList(instance any) fieldWhiteListedFunc {
|
||||
name := reflect.TypeOf(instance).String()
|
||||
registerFieldWhiteList(name, instance)
|
||||
return getFieldWhiteListedFunc(name)
|
||||
}
|
||||
|
||||
func registerFieldWhiteList(name string, instance any) {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
if whiteList[name] != nil {
|
||||
return
|
||||
}
|
||||
m := structs.Map(instance)
|
||||
whiteList[name] = map[string]struct{}{}
|
||||
for k := range m {
|
||||
whiteList[name][toSnakeCase(k)] = struct{}{}
|
||||
}
|
||||
ma := structs.Map(model.Annotations{})
|
||||
for k := range ma {
|
||||
whiteList[name][toSnakeCase(k)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
type fieldWhiteListedFunc func(field string) bool
|
||||
|
||||
func getFieldWhiteListedFunc(tableName string) fieldWhiteListedFunc {
|
||||
return func(field string) bool {
|
||||
mutex.RLock()
|
||||
defer mutex.RUnlock()
|
||||
if _, ok := whiteList[tableName]; !ok {
|
||||
return false
|
||||
}
|
||||
_, ok := whiteList[tableName][field]
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -9,31 +11,31 @@ import (
|
||||
|
||||
var _ = Describe("sqlRestful", func() {
|
||||
Describe("parseRestFilters", func() {
|
||||
var r sqlRestful
|
||||
var r sqlRepository
|
||||
var options rest.QueryOptions
|
||||
|
||||
BeforeEach(func() {
|
||||
r = sqlRestful{}
|
||||
r = sqlRepository{}
|
||||
})
|
||||
|
||||
It("returns nil if filters is empty", func() {
|
||||
options.Filters = nil
|
||||
Expect(r.parseRestFilters(options)).To(BeNil())
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns a '=' condition for 'id' filter", func() {
|
||||
options.Filters = map[string]interface{}{"id": "123"}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
|
||||
})
|
||||
|
||||
It("returns a 'in' condition for multiples 'id' filters", func() {
|
||||
options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
|
||||
})
|
||||
|
||||
It("returns a 'like' condition for other filters", func() {
|
||||
options.Filters = map[string]interface{}{"name": "joe"}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
|
||||
})
|
||||
|
||||
It("uses the custom filter", func() {
|
||||
@@ -43,7 +45,7 @@ var _ = Describe("sqlRestful", func() {
|
||||
},
|
||||
}
|
||||
options.Filters = map[string]interface{}{"test": 100}
|
||||
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
func getFullText(text ...string) string {
|
||||
fullText := utils.SanitizeStrings(text...)
|
||||
fullText := str.SanitizeStrings(text...)
|
||||
return " " + fullText
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
|
||||
}
|
||||
|
||||
func fullTextExpr(value string) Sqlizer {
|
||||
q := utils.SanitizeStrings(value)
|
||||
q := str.SanitizeStrings(value)
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,14 +12,13 @@ import (
|
||||
|
||||
type transcodingRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
func NewTranscodingRepository(ctx context.Context, db dbx.Builder) model.TranscodingRepository {
|
||||
r := &transcodingRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "transcoding"
|
||||
r.registerModel(&model.Transcoding{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -47,7 +46,7 @@ func (r *transcodingRepository) Put(t *model.Transcoding) error {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), r.parseRestOptions(options...))
|
||||
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Read(id string) (interface{}, error) {
|
||||
@@ -55,7 +54,7 @@ func (r *transcodingRepository) Read(id string) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(options...)).Columns("*")
|
||||
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
|
||||
res := model.Transcodings{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
|
||||
type userRepository struct {
|
||||
sqlRepository
|
||||
sqlRestful
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -34,7 +33,9 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
|
||||
r := &userRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "user"
|
||||
r.registerModel(&model.User{}, map[string]filterFunc{
|
||||
"password": invalidFilter(ctx),
|
||||
})
|
||||
once.Do(func() {
|
||||
_ = r.initPasswordEncryptionKey()
|
||||
})
|
||||
@@ -91,7 +92,7 @@ func (r *userRepository) FindFirstAdmin() (*model.User, error) {
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Like{"user_name": username})
|
||||
sel := r.newSelect().Columns("*").Where(Expr("user_name = ? COLLATE NOCASE", username))
|
||||
var usr model.User
|
||||
err := r.queryOne(sel, &usr)
|
||||
return &usr, err
|
||||
@@ -123,10 +124,10 @@ func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
if !usr.IsAdmin {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *userRepository) Read(id string) (interface{}, error) {
|
||||
func (r *userRepository) Read(id string) (any, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin && usr.ID != id {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
@@ -138,23 +139,23 @@ func (r *userRepository) Read(id string) (interface{}, error) {
|
||||
return usr, err
|
||||
}
|
||||
|
||||
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
}
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *userRepository) EntityName() string {
|
||||
return "user"
|
||||
}
|
||||
|
||||
func (r *userRepository) NewInstance() interface{} {
|
||||
func (r *userRepository) NewInstance() any {
|
||||
return &model.User{}
|
||||
}
|
||||
|
||||
func (r *userRepository) Save(entity interface{}) (string, error) {
|
||||
func (r *userRepository) Save(entity any) (string, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
return "", rest.ErrPermissionDenied
|
||||
@@ -170,7 +171,7 @@ func (r *userRepository) Save(entity interface{}) (string, error) {
|
||||
return u.ID, err
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
func (r *userRepository) Update(id string, entity any, _ ...string) error {
|
||||
u := entity.(*model.User)
|
||||
u.ID = id
|
||||
usr := loggedUser(r.ctx)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@@ -18,7 +19,7 @@ var _ = Describe("UserRepository", func() {
|
||||
var repo model.UserRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = NewUserRepository(log.NewContext(context.TODO()), getDBXBuilder())
|
||||
repo = NewUserRepository(log.NewContext(context.TODO()), NewDBXBuilder(db.Db()))
|
||||
})
|
||||
|
||||
Describe("Put/Get/FindByUsername", func() {
|
||||
|
||||
@@ -11,19 +11,12 @@ import (
|
||||
"github.com/navidrome/navidrome/utils/merge"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed *
|
||||
embedFS embed.FS
|
||||
fsOnce sync.Once
|
||||
fsys fs.FS
|
||||
)
|
||||
//go:embed *
|
||||
var embedFS embed.FS
|
||||
|
||||
func FS() fs.FS {
|
||||
fsOnce.Do(func() {
|
||||
fsys = merge.FS{
|
||||
Base: embedFS,
|
||||
Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")),
|
||||
}
|
||||
})
|
||||
return fsys
|
||||
}
|
||||
var FS = sync.OnceValue(func() fs.FS {
|
||||
return merge.FS{
|
||||
Base: embedFS,
|
||||
Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -440,7 +440,7 @@
|
||||
"totalScanned": "Insgesamt gescannte Ordner",
|
||||
"quickScan": "Schneller Scan",
|
||||
"fullScan": "Kompletter Scan",
|
||||
"serverUptime": "Server Uptime",
|
||||
"serverUptime": "Server-Betriebszeit",
|
||||
"serverDown": "OFFLINE"
|
||||
},
|
||||
"help": {
|
||||
|
||||
466
resources/i18n/eu.json
Normal file
466
resources/i18n/eu.json
Normal file
@@ -0,0 +1,466 @@
|
||||
|
||||
{
|
||||
"languageName": "Euskara",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Abestia |||| Abestiak",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"duration": "Iraupena",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Erreprodukzioak",
|
||||
"title": "Titulua",
|
||||
"artist": "Artista",
|
||||
"album": "Albuma",
|
||||
"path": "Fitxategiaren bidea",
|
||||
"genre": "Generoa",
|
||||
"compilation": "Konpilazioa",
|
||||
"year": "Urtea",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"bitRate": "Bit tasa",
|
||||
"channels": "Kanalak",
|
||||
"discSubtitle": "Diskoaren azpititulua",
|
||||
"starred": "Gogokoa",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"quality": "Kalitatea",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Azkenekoz erreproduzitua:",
|
||||
"createdAt": "Gehitu zen data:"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Erreproduzitu ondoren",
|
||||
"playNow": "Erreproduzitu orain",
|
||||
"addToPlaylist": "Gehitu erreprodukzio-zerrendara",
|
||||
"shuffleAll": "Erreprodukzio aleatorioa",
|
||||
"download": "Deskargatu",
|
||||
"playNext": "Hurrengoa",
|
||||
"info": "Lortu informazioa"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Albuma |||| Albumak",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"artist": "Artista",
|
||||
"duration": "Iraupena",
|
||||
"songCount": "abesti",
|
||||
"playCount": "Erreprodukzioak",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"name": "Izena",
|
||||
"genre": "Generoa",
|
||||
"compilation": "Konpilazioa",
|
||||
"year": "Urtea",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data:",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"released": "Argitaratua",
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"createdAt": "Gehitu zen data:"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Erreproduzitu",
|
||||
"playNext": "Erreproduzitu segidan",
|
||||
"addToQueue": "Erreproduzitu amaieran",
|
||||
"share": "Partekatu",
|
||||
"shuffle": "Aletorioa",
|
||||
"addToPlaylist": "Gehitu zerrendara",
|
||||
"download": "Deskargatu",
|
||||
"info": "Lortu informazioa"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Guztiak",
|
||||
"random": "Aleatorioa",
|
||||
"recentlyAdded": "Berriki gehitutakoak",
|
||||
"recentlyPlayed": "Berriki entzundakoak",
|
||||
"mostPlayed": "Gehien entzundakoak",
|
||||
"starred": "Gogokoak",
|
||||
"topRated": "Hobekien baloratutakoak"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artista |||| Artistak",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
"songCount": "Abesti kopurua",
|
||||
"size": "Tamaina",
|
||||
"playCount": "Erreprodukzio kopurua",
|
||||
"rating": "Balorazioa",
|
||||
"genre": "Generoa"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Erabiltzailea |||| Erabiltzaileak",
|
||||
"fields": {
|
||||
"userName": "Erabiltzailearen izena",
|
||||
"isAdmin": "Administratzailea da",
|
||||
"lastLoginAt": "Azken sartze-data:",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"name": "Izena",
|
||||
"password": "Pasahitza",
|
||||
"createdAt": "Sortze-data:",
|
||||
"changePassword": "Pasahitza aldatu?",
|
||||
"currentPassword": "Uneko pasahitza",
|
||||
"newPassword": "Pasahitz berria",
|
||||
"token": "Tokena"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Erabiltzailea sortu da",
|
||||
"updated": "Erabiltzailea eguneratu da",
|
||||
"deleted": "Erabiltzailea ezabatu da"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
|
||||
"clickHereForToken": "Egin klik hemen tokena lortzeko"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Erreproduktorea |||| Erreproduktoreak",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"transcodingId": "Transkodifikazioa",
|
||||
"maxBitRate": "Gehienezko bit tasa",
|
||||
"client": "Bezeroa",
|
||||
"userName": "Erabiltzailea",
|
||||
"lastSeen": "Azken konexioa",
|
||||
"reportRealPath": "Erakutsi bide absolutua",
|
||||
"scrobbleEnabled": "Bidali erabiltzailearen ohiturak hirugarrenen zerbitzuetara"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transkodeketa |||| Transkodeketak",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"targetFormat": "Helburuko formatua",
|
||||
"defaultBitRate": "Bit tasa, defektuz",
|
||||
"command": "Komandoa"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Zerrenda |||| Zerrendak",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"duration": "Iraupena",
|
||||
"ownerName": "Jabea",
|
||||
"public": "Publikoa",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"createdAt": "Sortze-data:",
|
||||
"songCount": "abesti",
|
||||
"comment": "Iruzkina",
|
||||
"sync": "Automatikoki inportatuak",
|
||||
"path": "Inportatze-data:"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Hautatu zerrenda:",
|
||||
"addNewPlaylist": "Sortu \"%{name}\"",
|
||||
"export": "Esportatu",
|
||||
"makePublic": "Egin publikoa",
|
||||
"makePrivate": "Egin pribatua"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Hautatutako abesti batzuk lehendik ere daude zerrendan",
|
||||
"song_exist": "Bikoiztutakoak gehitzen ari dira erreprodukzio-zerrendara. Ziur gehitu nahi dituzula?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Irratia |||| Irratiak",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"streamUrl": "Jarioaren URLa",
|
||||
"homePageUrl": "Web orriaren URLa",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"createdAt": "Sortze-data:"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Erreproduzitu orain"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Partekatu",
|
||||
"fields": {
|
||||
"username": "Partekatzailea:",
|
||||
"url": "URLa",
|
||||
"description": "Deskribapena",
|
||||
"downloadable": "Deskargatzea ahalbidetu?",
|
||||
"contents": "Edukia",
|
||||
"expiresAt": "Iraungitze-data:",
|
||||
"lastVisitedAt": "Azkenekoz bisitatu zen:",
|
||||
"visitCount": "Bisita kopurua",
|
||||
"format": "Formatua",
|
||||
"maxBitRate": "Gehienezko bit tasa",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"createdAt": "Sortze-data:"
|
||||
},
|
||||
"notifications": {
|
||||
},
|
||||
"actions": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Eskerrik asko Navidrome instalatzeagatik!",
|
||||
"welcome2": "Lehenik eta behin, sortu administratzaile kontua",
|
||||
"confirmPassword": "Baieztatu pasahitza",
|
||||
"buttonCreateAdmin": "Sortu administratzailea",
|
||||
"auth_check_error": "Hasi saioa aurrera egiteko",
|
||||
"user_menu": "Profila",
|
||||
"username": "Erabiltzailea",
|
||||
"password": "Pasahitza",
|
||||
"sign_in": "Sartu",
|
||||
"sign_in_error": "Autentifikazioak huts egin du, saiatu berriro",
|
||||
"logout": "Itxi saioa"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Erabili hizkiak eta zenbakiak bakarrik",
|
||||
"passwordDoesNotMatch": "Pasahitzak ez datoz bat",
|
||||
"required": "Beharrezkoa",
|
||||
"minLength": "Gutxienez %{min} karaktere izan behar ditu",
|
||||
"maxLength": "Gehienez %{max} karaktere izan ditzake",
|
||||
"minValue": "Gutxienez %{min} izan behar da",
|
||||
"maxValue": "Gehienez %{max} izan daiteke",
|
||||
"number": "Zenbakia izan behar da",
|
||||
"email": "Baliozko ePosta helbidea izan behar da",
|
||||
"oneOf": "Hauetako bat izan behar da: %{options}",
|
||||
"regex": "Formatu zehatzarekin bat etorri behar da (regexp): %{pattern}",
|
||||
"unique": "Bakarra izan behar da",
|
||||
"url": "Baliozko URLa izan behar da"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Gehitu iragazkia",
|
||||
"add": "Gehitu",
|
||||
"back": "Itzuli",
|
||||
"bulk_actions": "elementu 1 hautatuta |||| %{smart_count} elementu hautatuta",
|
||||
"cancel": "Utzi",
|
||||
"clear_input_value": "Garbitu balioa",
|
||||
"clone": "Bikoiztu",
|
||||
"confirm": "Baieztatu",
|
||||
"create": "Sortu",
|
||||
"delete": "Ezabatu",
|
||||
"edit": "Editatu",
|
||||
"export": "Esportatu",
|
||||
"list": "Zerrenda",
|
||||
"refresh": "Freskatu",
|
||||
"remove_filter": "Ezabatu iragazkia",
|
||||
"remove": "Ezabatu",
|
||||
"save": "Gorde",
|
||||
"search": "Bilatu",
|
||||
"show": "Erakutsi",
|
||||
"sort": "Ordenatu",
|
||||
"undo": "Desegin",
|
||||
"expand": "Hedatu",
|
||||
"close": "Itxi",
|
||||
"open_menu": "Ireki menua",
|
||||
"close_menu": "Itxi menua",
|
||||
"unselect": "Utzi hautatzeari",
|
||||
"skip": "Utzi alde batera",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Partekatu",
|
||||
"download": "Deskargatu"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Bai",
|
||||
"false": "Ez"
|
||||
},
|
||||
"page": {
|
||||
"create": "Sortu %{name}",
|
||||
"dashboard": "Mahaigaina",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Zerbaitek huts egin du",
|
||||
"list": "%{name}",
|
||||
"loading": "Kargatzen",
|
||||
"not_found": "Ez da aurkitu",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Oraindik ez dago %{name}(r)ik.",
|
||||
"invite": "Sortu nahi al duzu?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.",
|
||||
"upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.",
|
||||
"upload_single": "Jaregin edo hautatu igo nahi duzun irudia."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Ezin dira erreferentziazko datuak aurkitu.",
|
||||
"many_missing": "Erreferentzietako bat gutxieenez ez dago eskuragai.",
|
||||
"single_missing": "Ez dirudi erreferentzia eskuragai dagoenik."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Ezkutatu pasahitza",
|
||||
"toggle_hidden": "Erakutsi pasahitza"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Honi buruz",
|
||||
"are_you_sure": "Ziur zaude?",
|
||||
"bulk_delete_content": "Ziur %{name} ezabatu nahi duzula? |||| Ziur %{smart_count} hauek ezabatu nahi dituzula?",
|
||||
"bulk_delete_title": "Ezabatu %{name} |||| Ezabatu %{smart_count} %{name}",
|
||||
"delete_content": "Ziur elementu hau ezabatu nahi duzula?",
|
||||
"delete_title": "Ezabatu %{name} #%{id}",
|
||||
"details": "Xehetasunak",
|
||||
"error": "Bezeroan errorea gertatu da eta eskaera ezin izan da gauzatu",
|
||||
"invalid_form": "Formularioa ez da baliozkoa. Egiaztatu errorerik ez dagoela",
|
||||
"loading": "Orria kargatzen ari da, itxaron",
|
||||
"no": "Ez",
|
||||
"not_found": "URLa ez da zuzena edo jarraitutako esteka akastuna da.",
|
||||
"yes": "Bai",
|
||||
"unsaved_changes": "Ez dira aldaketa batzuk gorde. Ziur muzin egin nahi diezula?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Ez da emaitzarik aurkitu",
|
||||
"no_more_results": "%{page} orrialde-zenbakia mugetatik kanpo dago. Saiatu aurreko orrialdearekin.",
|
||||
"page_out_of_boundaries": "%{page} orrialde-zenbakia mugetatik kanpo dago",
|
||||
"page_out_from_end": "Ezin zara azken orrialdea baino haratago joan",
|
||||
"page_out_from_begin": "Ezin zara lehenengo orrialdea baino aurrerago joan",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd}, %{total} guztira",
|
||||
"page_rows_per_page": "Errenkadak orrialdeko:",
|
||||
"next": "Hurrengoa",
|
||||
"prev": "Aurrekoa",
|
||||
"skip_nav": "Joan edukira"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira",
|
||||
"created": "Elementua sortu da",
|
||||
"deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.",
|
||||
"bad_item": "Elementu okerra",
|
||||
"item_doesnt_exist": "Elementua ez dago",
|
||||
"http_error": "Errorea zerbitzariarekin komunikatzerakoan",
|
||||
"data_provider_error": "Errorea datuen hornitzailean. Berrikusi kontsola xehetasun gehiagorako.",
|
||||
"i18n_error": "Ezin izan dira zehaztutako hizkuntzaren itzulpenak kargatu",
|
||||
"canceled": "Ekintza bertan behera utzi da",
|
||||
"logged_out": "Saioa amaitu da, konektatu berriro.",
|
||||
"new_version": "Bertsio berria eskuragai! Freskatu leihoa."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Erakusteko zutabeak",
|
||||
"layout": "Antolaketa",
|
||||
"grid": "Sareta",
|
||||
"table": "Taula"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "OHARRA",
|
||||
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
|
||||
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
|
||||
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
|
||||
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
|
||||
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
|
||||
"delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?",
|
||||
"notifications_blocked": "Nabigatzaileak jakinarazpenak blokeatzen ditu",
|
||||
"notifications_not_available": "Nabigatzaile hau ez da jakinarazpenekin bateragarria edo Navidrome ez da HTTPS erabiltzen ari",
|
||||
"lastfmLinkSuccess": "Last.fm konektatuta dago eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea gaituta dago",
|
||||
"lastfmLinkFailure": "Ezin izan da Last.fm-rekin konektatu",
|
||||
"lastfmUnlinkSuccess": "Last.fm deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea ezgaitu da",
|
||||
"lastfmUnlinkFailure": "Ezin izan da Last.fm deskonektatu",
|
||||
"openIn": {
|
||||
"lastfm": "Ikusi Last.fm-n",
|
||||
"musicbrainz": "Ikusi MusicBrainz-en"
|
||||
},
|
||||
"lastfmLink": "Irakurri gehiago…",
|
||||
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
|
||||
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua",
|
||||
"shareOriginalFormat": "Partekatu jatorrizko formatua",
|
||||
"shareDialogTitle": "Partekatu '%{name}' %{resource}",
|
||||
"shareBatchDialogTitle": "Partekatu %{resource} bat |||| Partekatu %{smart_count} %{resource}",
|
||||
"shareSuccess": "URLa arbelera kopiatu da: %{url}",
|
||||
"shareFailure": "Errorea %{url} URLa arbelera kopiatzean",
|
||||
"downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})",
|
||||
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Liburutegia",
|
||||
"settings": "Ezarpenak",
|
||||
"version": "Bertsioa",
|
||||
"theme": "Itxura",
|
||||
"personal": {
|
||||
"name": "Pertsonala",
|
||||
"options": {
|
||||
"theme": "Itxura",
|
||||
"language": "Hizkuntza",
|
||||
"defaultView": "Bista, defektuz",
|
||||
"desktop_notifications": "Mahaigaineko jakinarazpenak",
|
||||
"lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak",
|
||||
"listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak",
|
||||
"replaygain": "ReplayGain modua",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"gain": {
|
||||
"none": "Bat ere ez",
|
||||
"album": "Albuma",
|
||||
"track": "Pista"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albumak",
|
||||
"playlists": "Zerrendak",
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
||||
"about": "Honi buruz"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Erreprodukzio-zerrenda",
|
||||
"openText": "Ireki",
|
||||
"closeText": "Itxi",
|
||||
"notContentText": "Ez dago musikarik",
|
||||
"clickToPlayText": "Egin klik erreproduzitzeko",
|
||||
"clickToPauseText": "Egin klik eteteko",
|
||||
"nextTrackText": "Hurrengo pista",
|
||||
"previousTrackText": "Aurreko pista",
|
||||
"reloadText": "Freskatu",
|
||||
"volumeText": "Bolumena",
|
||||
"toggleLyricText": "Erakutsi letrak",
|
||||
"toggleMiniModeText": "Ikonotu",
|
||||
"destroyText": "Suntsitu",
|
||||
"downloadText": "Deskargatu",
|
||||
"removeAudioListsText": "Ezabatu audio-zerrendak",
|
||||
"clickToDeleteText": "Egin klik %{name} ezabatzeko",
|
||||
"emptyLyricText": "Ez dago letrarik",
|
||||
"playModeText": {
|
||||
"order": "Ordenean",
|
||||
"orderLoop": "Errepikatu",
|
||||
"singleLoop": "Errepikatu bakarra",
|
||||
"shufflePlay": "Aleatorioa"
|
||||
}
|
||||
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Hasierako orria",
|
||||
"source": "Iturburu kodea",
|
||||
"featureRequests": "Eskatu ezaugarria"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Ekintzak",
|
||||
"totalScanned": "Arakatutako karpeta guztiak",
|
||||
"quickScan": "Arakatze azkarra",
|
||||
"fullScan": "Arakatze sakona",
|
||||
"serverUptime": "Zerbitzariak piztuta daraman denbora",
|
||||
"serverDown": "LINEAZ KANPO"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidromeren laster-teklak",
|
||||
"hotkeys": {
|
||||
"show_help": "Erakutsi laguntza",
|
||||
"toggle_menu": "Alboko barra bai / ez",
|
||||
"toggle_play": "Erreproduzitu / Eten",
|
||||
"prev_song": "Aurreko abestia",
|
||||
"next_song": "Hurrengo abestia",
|
||||
"vol_up": "Igo bolumena",
|
||||
"vol_down": "Jaitsi bolumena",
|
||||
"toggle_love": "Abestia gogoko bai / ez",
|
||||
"current_song": "Uneko abestia"
|
||||
}
|
||||
}
|
||||
}
|
||||
460
resources/i18n/hu.json
Normal file
460
resources/i18n/hu.json
Normal file
@@ -0,0 +1,460 @@
|
||||
{
|
||||
"languageName": "Magyar",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Szám |||| Számok",
|
||||
"fields": {
|
||||
"albumArtist": "Album előadó",
|
||||
"duration": "Hossz",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Lejátszások",
|
||||
"title": "Cím",
|
||||
"artist": "Előadó",
|
||||
"album": "Album",
|
||||
"path": "Elérési út",
|
||||
"genre": "Műfaj",
|
||||
"compilation": "Válogatásalbum",
|
||||
"year": "Év",
|
||||
"size": "Fájlméret",
|
||||
"updatedAt": "Legutóbb frissítve",
|
||||
"bitRate": "Bitráta",
|
||||
"discSubtitle": "Lemezfelirat",
|
||||
"starred": "Kedvenc",
|
||||
"comment": "Megjegyzés",
|
||||
"rating": "Értékelés",
|
||||
"quality": "Minőség",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Utoljára lejátszva",
|
||||
"channels": "Csatornák",
|
||||
"createdAt": "Hozzáadva"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lejátszás útolsóként",
|
||||
"playNow": "Lejátszás",
|
||||
"addToPlaylist": "Lejátszási listához adás",
|
||||
"shuffleAll": "Keverés",
|
||||
"download": "Letöltés",
|
||||
"playNext": "Lejátszás következőként",
|
||||
"info": "Részletek"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Albumok",
|
||||
"fields": {
|
||||
"albumArtist": "Album előadó",
|
||||
"artist": "Előadó",
|
||||
"duration": "Hossz",
|
||||
"songCount": "Számok",
|
||||
"playCount": "Lejátszások",
|
||||
"name": "Név",
|
||||
"genre": "Stílus",
|
||||
"compilation": "Válogatásalbum",
|
||||
"year": "Év",
|
||||
"updatedAt": "Legutóbb frissítve",
|
||||
"comment": "Megjegyzés",
|
||||
"rating": "Értékelés",
|
||||
"createdAt": "Létrehozva",
|
||||
"size": "Méret",
|
||||
"originalDate": "Eredeti",
|
||||
"releaseDate": "Kiadva",
|
||||
"releases": "Kiadó |||| Kiadók",
|
||||
"released": "Kiadta"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Lejátszás",
|
||||
"playNext": "Lejátszás következőként",
|
||||
"addToQueue": "Lejátszás útolsóként",
|
||||
"shuffle": "Keverés",
|
||||
"addToPlaylist": "Lejátszási listához adás",
|
||||
"download": "Letöltés",
|
||||
"info": "Részletek",
|
||||
"share": "Megosztás"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Mind",
|
||||
"random": "Véletlenszerű",
|
||||
"recentlyAdded": "Nemrég hozzáadott",
|
||||
"recentlyPlayed": "Nemrég lejátszott",
|
||||
"mostPlayed": "Legtöbbször lejátszott",
|
||||
"starred": "Kedvencek",
|
||||
"topRated": "Legjobbra értékelt"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Előadó |||| Előadók",
|
||||
"fields": {
|
||||
"name": "Név",
|
||||
"albumCount": "Albumok száma",
|
||||
"songCount": "Számok száma",
|
||||
"playCount": "Lejátszások",
|
||||
"rating": "Értékelés",
|
||||
"genre": "Stílus",
|
||||
"size": "Méret"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Felhasználó |||| Felhasználók",
|
||||
"fields": {
|
||||
"userName": "Felhasználónév",
|
||||
"isAdmin": "Admin",
|
||||
"lastLoginAt": "Utolsó belépés",
|
||||
"updatedAt": "Legutóbb frissítve",
|
||||
"name": "Név",
|
||||
"password": "Jelszó",
|
||||
"createdAt": "Létrehozva",
|
||||
"changePassword": "Jelszó módosítása?",
|
||||
"currentPassword": "Jelenlegi jelszó",
|
||||
"newPassword": "Új jelszó",
|
||||
"token": "Token"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "A névváltoztatások csak a következő bejelentkezéskor jelennek meg"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Felhasználó létrehozva",
|
||||
"updated": "Felhasználó frissítve",
|
||||
"deleted": "Felhasználó törölve"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Add meg a ListenBrainz felhasználó tokened.",
|
||||
"clickHereForToken": "Kattints ide, hogy megszerezd a tokened"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Lejátszó |||| Lejátszók",
|
||||
"fields": {
|
||||
"name": "Név",
|
||||
"transcodingId": "Átkódolás",
|
||||
"maxBitRate": "Max. bitráta",
|
||||
"client": "Kliens",
|
||||
"userName": "Felhasználó név",
|
||||
"lastSeen": "Utoljára bejelentkezett",
|
||||
"reportRealPath": "Valódi fájlútvonal küldése",
|
||||
"scrobbleEnabled": "Statisztika küldése külső szolgáltatásoknak"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Átkódolás |||| Átkódolások",
|
||||
"fields": {
|
||||
"name": "Név",
|
||||
"targetFormat": "Cél formátum",
|
||||
"defaultBitRate": "Alapértelmezett bitráta",
|
||||
"command": "Parancs"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Lejátszási lista |||| Lejátszási listák",
|
||||
"fields": {
|
||||
"name": "Név",
|
||||
"duration": "Hossz",
|
||||
"ownerName": "Tulajdonos",
|
||||
"public": "Publikus",
|
||||
"updatedAt": "Frissítve",
|
||||
"createdAt": "Létrehozva",
|
||||
"songCount": "Számok",
|
||||
"comment": "Megjegyzés",
|
||||
"sync": "Auto-importálás",
|
||||
"path": "Importálás"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Válassz egy lejátszási listát:",
|
||||
"addNewPlaylist": "\"%{name}\" létrehozása",
|
||||
"export": "Exportálás",
|
||||
"makePublic": "Publikussá tétel",
|
||||
"makePrivate": "Priváttá tétel"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Duplikált számok hozzáadása",
|
||||
"song_exist": "Egyes számok már hozzá vannak adva a listához. Még egyszer hozzá akarod adni?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Radió |||| Radiók",
|
||||
"fields": {
|
||||
"name": "Név",
|
||||
"streamUrl": "Stream URL",
|
||||
"homePageUrl": "Honlap URL",
|
||||
"updatedAt": "Frissítve",
|
||||
"createdAt": "Létrehozva"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Lejátszás"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Megosztás |||| Megosztások",
|
||||
"fields": {
|
||||
"username": "Megosztotta",
|
||||
"url": "URL",
|
||||
"description": "Leírás",
|
||||
"contents": "Tartalom",
|
||||
"expiresAt": "Lejárat",
|
||||
"lastVisitedAt": "Utoljára látogatva",
|
||||
"visitCount": "Látogatók",
|
||||
"format": "Formátum",
|
||||
"maxBitRate": "Max. bitráta",
|
||||
"updatedAt": "Frissítve",
|
||||
"createdAt": "Létrehozva",
|
||||
"downloadable": "Engedélyezed a letöltéseket?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Köszönjük, hogy a Navidrome-ot telepítetted!",
|
||||
"welcome2": "A kezdéshez hozz létre egy admin felhasználót!",
|
||||
"confirmPassword": "Jelszó megerősítése",
|
||||
"buttonCreateAdmin": "Admin hozzáadása",
|
||||
"auth_check_error": "Jelentkezz be a folytatáshoz!",
|
||||
"user_menu": "Profil",
|
||||
"username": "Felhasználó név",
|
||||
"password": "Jelszó",
|
||||
"sign_in": "Bejelentkezés",
|
||||
"sign_in_error": "A hitelesítés sikertelen. Kérjük, próbáld újra!",
|
||||
"logout": "Kijelentkezés"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Kérlek, csak betűket és számokat használj!",
|
||||
"passwordDoesNotMatch": "A jelszó nem egyezik.",
|
||||
"required": "Szükséges",
|
||||
"minLength": "Legalább %{min} karakternek kell lennie",
|
||||
"maxLength": "Legfeljebb %{max} karakternek kell lennie",
|
||||
"minValue": "Legalább %{min}",
|
||||
"maxValue": "Legfeljebb %{max} vagy kevesebb",
|
||||
"number": "Számnak kell lennie",
|
||||
"email": "Érvényes email címnek kell lennie",
|
||||
"oneOf": "Az egyiknek kell lennie: %{options}",
|
||||
"regex": "Meg kell felelnie egy adott formátumnak (regexp): %{pattern}",
|
||||
"unique": "Egyedinek kell lennie",
|
||||
"url": "Érvényes URL-nek kell lennie"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Szűrő hozzáadása",
|
||||
"add": "Hozzáadás",
|
||||
"back": "Vissza",
|
||||
"bulk_actions": "1 kiválasztott elem |||| %{smart_count} kiválasztott elem",
|
||||
"cancel": "Mégse",
|
||||
"clear_input_value": "Üres érték",
|
||||
"clone": "Klónozás",
|
||||
"confirm": "Megerősítés",
|
||||
"create": "Létrehozás",
|
||||
"delete": "Törlés",
|
||||
"edit": "Szerkesztés",
|
||||
"export": "Exportálás",
|
||||
"list": "Lista",
|
||||
"refresh": "Frissítés",
|
||||
"remove_filter": "Szűrő eltávolítása",
|
||||
"remove": "Eltávolítás",
|
||||
"save": "Mentés",
|
||||
"search": "Keresés",
|
||||
"show": "Megjelenítés",
|
||||
"sort": "Rendezés",
|
||||
"undo": "Vísszavonás",
|
||||
"expand": "Kiterjesztés",
|
||||
"close": "Bezárás",
|
||||
"open_menu": "Menü megnyitása",
|
||||
"close_menu": "Menü bezárása",
|
||||
"unselect": "Kijelölés törlése",
|
||||
"skip": "Átugrás",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Megosztás",
|
||||
"download": "Letöltés"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Igen",
|
||||
"false": "Nem"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} létrehozása",
|
||||
"dashboard": "Műszerfal",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Valami probléma történt",
|
||||
"list": "%{name}",
|
||||
"loading": "Betöltés",
|
||||
"not_found": "Nem található",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Nincs %{name} még.",
|
||||
"invite": "Szeretnél egyet hozzáadni?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Húzz ide néhány feltöltendő fájlt vagy válassz egyet.",
|
||||
"upload_single": "Húzz ide egy feltöltendő fájlt vagy válassz egyet."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Húzz ide néhány feltöltendő képet vagy válassz egyet.",
|
||||
"upload_single": "Húzz ide egy feltöltendő képet vagy válassz egyet."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Hivatkozási adatok nem találhatóak.",
|
||||
"many_missing": "Legalább az egyik kapcsolódó hivatkozás már nem elérhető.",
|
||||
"single_missing": "A kapcsolódó hivatkozás már nem elérhető."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Jelszó elrejtése",
|
||||
"toggle_hidden": "Jelszó megjelenítése"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Rólunk",
|
||||
"are_you_sure": "Biztos vagy benne?",
|
||||
"bulk_delete_content": "Biztos, hogy törölni akarod %{name}? |||| Biztos, hogy törölni akarod ezeket az %{smart_count} elemeket?",
|
||||
"bulk_delete_title": "%{name} törlése |||| %{smart_count} %{name} elem törlése",
|
||||
"delete_content": "Biztos, hogy törlöd ezt az elemet?",
|
||||
"delete_title": "%{name} #%{id} törlése",
|
||||
"details": "Részletek",
|
||||
"error": "Kliens hiba lépett fel, és a kérést nem lehetett teljesíteni.",
|
||||
"invalid_form": "Az űrlap érvénytelen. Kérlek, ellenőrizzd a hibákat.",
|
||||
"loading": "Az oldal betöltődik. Egy pillanat.",
|
||||
"no": "Nem",
|
||||
"not_found": "Rossz hivatkozást írtál be, vagy egy rossz linket adtál meg.",
|
||||
"yes": "Igen",
|
||||
"unsaved_changes": "Néhány módosítás nem lett elmentve. Biztos, hogy figyelmen kívül akarod hagyni?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Nincs találat.",
|
||||
"no_more_results": "Az oldalszám %{page} kívül esik a határokon. Próbáld meg az előző oldalt.",
|
||||
"page_out_of_boundaries": "Az oldalszám %{page} kívül esik a határokon.",
|
||||
"page_out_from_end": "Nem lehet az utolsó oldal után menni",
|
||||
"page_out_from_begin": "Nem lehet az első oldal elé menni",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} of %{total}",
|
||||
"page_rows_per_page": "Elemek oldalanként:",
|
||||
"next": "Következő",
|
||||
"prev": "Előző",
|
||||
"skip_nav": "Ugrás a tartalomra"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Elem frissítve |||| %{smart_count} elemek frissíteve",
|
||||
"created": "Elem létrehozva",
|
||||
"deleted": "Elem törölve |||| %{smart_count} elemek frissítve",
|
||||
"bad_item": "Hibás elem",
|
||||
"item_doesnt_exist": "Elem nem létezik",
|
||||
"http_error": "Szerver kommunikációs hiba",
|
||||
"data_provider_error": "Adatszolgáltatói hiba. Ellenőrizzd a konzolt a részletekért.",
|
||||
"i18n_error": "Nem lehet betölteni a fordítást a kért nyelven",
|
||||
"canceled": "A művelet visszavonva",
|
||||
"logged_out": "A munkamenet lejárt. Kérlek, csatlakozz újra.",
|
||||
"new_version": "Új verzió elérhető! Kérlek, frissítsd ezt az ablakot!"
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Megjelenítendő oszlopok",
|
||||
"layout": "Elrendezés",
|
||||
"grid": "Rács",
|
||||
"table": "Tábla"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "MEGJEGYZÉS",
|
||||
"transcodingDisabled": "Az átkódolási konfiguráció módosítása a webes felületen keresztül biztonsági okokból nem lehetséges. Ha módosítani szeretnéd az átkódolási beállításokat, indítsd újra a kiszolgálót a %{config} konfigurációs opcióval.",
|
||||
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",
|
||||
"songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához",
|
||||
"noPlaylistsAvailable": "Nem áll rendelkezésre",
|
||||
"delete_user_title": "Felhasználó törlése '%{name}'",
|
||||
"delete_user_content": "Biztos, hogy törölni akarod ezt a felhasználót az adataival (beállítások és lejátszási listák) együtt?",
|
||||
"notifications_blocked": "A böngésződ beállításaiban letiltottad az értesítéseket erre az oldalra.",
|
||||
"notifications_not_available": "Ez a böngésző nem támogatja az asztali értesítéseket, vagy a Navidrome-ot nem https-en keresztül használod.",
|
||||
"lastfmLinkSuccess": "Sikeresen összekapcsolva Last.fm-el és halgatott számok küldése engedélyezve.",
|
||||
"lastfmLinkFailure": "Nem lehet kapcsolódni a Last.fm-hez.",
|
||||
"lastfmUnlinkSuccess": "Last.fm leválasztva és a halgatott számok küldése kikapcsolva.",
|
||||
"lastfmUnlinkFailure": "Nem sikerült leválasztani a Last.fm-et.",
|
||||
"openIn": {
|
||||
"lastfm": "Megnyitás Last.fm-ben",
|
||||
"musicbrainz": "Megnyitás MusicBrainz-ben"
|
||||
},
|
||||
"lastfmLink": "Bővebben...",
|
||||
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el és halgatott számok küldése %{user} felhasználónak engedélyezve.",
|
||||
"listenBrainzLinkFailure": "Nem lehet kapcsolódni a Listenbrainz-hez: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz Last.fm leválasztva és a halgatott számok küldése kikapcsolva.",
|
||||
"listenBrainzUnlinkFailure": "Nem sikerült leválasztani a ListenBrainz-et.",
|
||||
"downloadOriginalFormat": "Letöltés eredeti formátumban",
|
||||
"shareOriginalFormat": "Megosztás eredeti formátumban",
|
||||
"shareDialogTitle": "Megosztás %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "1 %{resource} megosztása |||| %{smart_count} %{resource} megosztása",
|
||||
"shareSuccess": "Hivatkozás másolva a vágólapra: %{url}",
|
||||
"shareFailure": "Hiba történt a hivatkozás %{url} vágólapra másolása közben.",
|
||||
"downloadDialogTitle": "Letöltés %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Másolás vágólapra: Ctrl+C, Enter"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Könyvtár",
|
||||
"settings": "Beállítások",
|
||||
"version": "Verzió",
|
||||
"theme": "Téma",
|
||||
"personal": {
|
||||
"name": "Személyes",
|
||||
"options": {
|
||||
"theme": "Téma",
|
||||
"language": "Nyelv",
|
||||
"defaultView": "Alapértelmezett nézet",
|
||||
"desktop_notifications": "Asztali értesítések",
|
||||
"lastfmScrobbling": "Halgatott számok küldése a Last.fm-nek",
|
||||
"listenBrainzScrobbling": "Halgatott számok küldése a ListenBrainz-nek",
|
||||
"replaygain": "ReplayGain mód",
|
||||
"preAmp": "ReplayGain előerősítő (dB)",
|
||||
"gain": {
|
||||
"none": "Kikapcsolva",
|
||||
"album": "Album",
|
||||
"track": "Sáv"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albumok",
|
||||
"about": "Rólunk",
|
||||
"playlists": "Lejátszási listák",
|
||||
"sharedPlaylists": "Megosztott lej. listák"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Lejátszási lista",
|
||||
"openText": "Megnyitás",
|
||||
"closeText": "Bezárás",
|
||||
"notContentText": "Nincs zene",
|
||||
"clickToPlayText": "Lejátszás",
|
||||
"clickToPauseText": "Szünet",
|
||||
"nextTrackText": "Következő szám",
|
||||
"previousTrackText": "Előző szám",
|
||||
"reloadText": "Újratöltés",
|
||||
"volumeText": "Hangerő",
|
||||
"toggleLyricText": "Zeneszöveg",
|
||||
"toggleMiniModeText": "Minimalizálás",
|
||||
"destroyText": "Bezárás",
|
||||
"downloadText": "Letöltés",
|
||||
"removeAudioListsText": "Audio listák törlése",
|
||||
"clickToDeleteText": "Kattints a törléshez %{name}",
|
||||
"emptyLyricText": "Nincs szöveg",
|
||||
"playModeText": {
|
||||
"order": "Sorrendben",
|
||||
"orderLoop": "Ismétlés",
|
||||
"singleLoop": "Egy szám ismétlése",
|
||||
"shufflePlay": "Véletlenszerű"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Honlap",
|
||||
"source": "Forráskód",
|
||||
"featureRequests": "Funkciókérések"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivitás",
|
||||
"totalScanned": "Beolvasott mappák összesen",
|
||||
"quickScan": "Gyors beolvasás",
|
||||
"fullScan": "Teljes beolvasás",
|
||||
"serverUptime": "Szerver üzemidő",
|
||||
"serverDown": "OFFLINE"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Gyorsbillentyűk",
|
||||
"hotkeys": {
|
||||
"show_help": "Mutasd ezt a súgót",
|
||||
"toggle_menu": "Menu oldalsáv be",
|
||||
"toggle_play": "Lejátszás / Szünet",
|
||||
"prev_song": "Előző Szám",
|
||||
"next_song": "Következő Szám",
|
||||
"vol_up": "Hangerő fel",
|
||||
"vol_down": "Hangerő le",
|
||||
"toggle_love": "Ad hozzá ezt a számot a kedvencekhez",
|
||||
"current_song": "Aktuális számhoz ugrás"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,139 +2,139 @@
|
||||
"languageName": "한국어",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "곡",
|
||||
"name": "노래 |||| 노래들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"duration": "길이",
|
||||
"duration": "시간",
|
||||
"trackNumber": "#",
|
||||
"playCount": "재생 수",
|
||||
"playCount": "재생 횟수",
|
||||
"title": "제목",
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"genre": "장르",
|
||||
"compilation": "Compilation",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"size": "파일 크기",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"updatedAt": "업데이트됨",
|
||||
"bitRate": "비트레이트",
|
||||
"discSubtitle": "디스크 서브타이틀",
|
||||
"starred": "좋아요",
|
||||
"comment": "코멘트",
|
||||
"starred": "즐겨찾기",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"quality": "품질",
|
||||
"bpm": "BPM",
|
||||
"playDate": "마지막 재생",
|
||||
"channels": "채널",
|
||||
"createdAt": "추가 날짜"
|
||||
"createdAt": "추가된 날짜"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "마지막에 재생",
|
||||
"playNow": "바로 재생",
|
||||
"addToPlaylist": "플레이리스트에 추가",
|
||||
"shuffleAll": "모든 곡 셔플",
|
||||
"addToQueue": "나중에 재생",
|
||||
"playNow": "지금 재생",
|
||||
"addToPlaylist": "재생목록에 추가",
|
||||
"shuffleAll": "모든 노래 셔플",
|
||||
"download": "다운로드",
|
||||
"playNext": "다음에 재생",
|
||||
"info": "상세 정보"
|
||||
"playNext": "다음 재생",
|
||||
"info": "정보"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "앨범",
|
||||
"name": "앨범 |||| 앨범들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"artist": "아티스트",
|
||||
"duration": "길이",
|
||||
"songCount": "곡",
|
||||
"playCount": "재생 수",
|
||||
"duration": "시간",
|
||||
"songCount": "노래",
|
||||
"playCount": "재생 횟수",
|
||||
"name": "이름",
|
||||
"genre": "장르",
|
||||
"compilation": "Compilation",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"comment": "코멘트",
|
||||
"updatedAt": "업데이트됨",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"createdAt": "추가 날짜",
|
||||
"createdAt": "추가된 날짜",
|
||||
"size": "크기",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": ""
|
||||
"originalDate": "오리지널",
|
||||
"releaseDate": "발매일",
|
||||
"releases": "발매 음반 |||| 발매 음반들",
|
||||
"released": "발매됨"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "재생",
|
||||
"playNext": "다음에 재생",
|
||||
"addToQueue": "마지막에 재생",
|
||||
"addToQueue": "나중에 재생",
|
||||
"shuffle": "셔플",
|
||||
"addToPlaylist": "플레이리스트에 추가",
|
||||
"addToPlaylist": "재생목록 추가",
|
||||
"download": "다운로드",
|
||||
"info": "상세 정보",
|
||||
"info": "정보",
|
||||
"share": "공유"
|
||||
},
|
||||
"lists": {
|
||||
"all": "전체",
|
||||
"all": "모두",
|
||||
"random": "랜덤",
|
||||
"recentlyAdded": "최근 추가",
|
||||
"recentlyPlayed": "최근 재생",
|
||||
"mostPlayed": "가장 많이 재생",
|
||||
"starred": "좋아요",
|
||||
"recentlyAdded": "최근 추가됨",
|
||||
"recentlyPlayed": "최근 재생됨",
|
||||
"mostPlayed": "가장 많이 재생됨",
|
||||
"starred": "즐겨찾기",
|
||||
"topRated": "높은 평가"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "아티스트",
|
||||
"name": "아티스트 |||| 아티스트들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"albumCount": "앨범 수",
|
||||
"songCount": "곡 수",
|
||||
"playCount": "재생 수",
|
||||
"songCount": "노래 수",
|
||||
"playCount": "재생 횟수",
|
||||
"rating": "평가",
|
||||
"genre": "장르",
|
||||
"size": "크기"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "사용자",
|
||||
"name": "사용자 |||| 사용자들",
|
||||
"fields": {
|
||||
"userName": "사용자명",
|
||||
"userName": "사용자이름",
|
||||
"isAdmin": "관리자",
|
||||
"lastLoginAt": "최종 로그인",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"lastLoginAt": "마지막 로그인",
|
||||
"updatedAt": "업데이트됨",
|
||||
"name": "이름",
|
||||
"password": "비밀번호",
|
||||
"createdAt": "생성 날짜",
|
||||
"changePassword": "비밀번호를 변경하시겠습니까?",
|
||||
"createdAt": "생성됨",
|
||||
"changePassword": "비밀번호를 변경할까요?",
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"newPassword": "새로운 비밀번호",
|
||||
"newPassword": "새 비밀번호",
|
||||
"token": "토큰"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "이름 변경은 다음 로그인 이후에 반영됩니다"
|
||||
"name": "이름 변경 사항은 다음 로그인 이후에 반영됨"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "사용자가 생성되었습니다",
|
||||
"updated": "사용자가 업데이트되었습니다",
|
||||
"deleted": "사용자가 삭제되었습니다"
|
||||
"created": "사용자 생성됨",
|
||||
"updated": "사용자 업데이트됨",
|
||||
"deleted": "사용자 삭제됨"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요",
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "플레이어",
|
||||
"name": "플레이어 |||| 플레이어들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"transcodingId": "트랜스코딩",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"client": "클라이언트",
|
||||
"userName": "사용자명",
|
||||
"lastSeen": "마지막 사용",
|
||||
"reportRealPath": "실제 파일 경로 반환",
|
||||
"scrobbleEnabled": "다른 서비스에 scrobble"
|
||||
"userName": "사용자이름",
|
||||
"lastSeen": "마지막으로 봤음",
|
||||
"reportRealPath": "실제 경로 보고서",
|
||||
"scrobbleEnabled": "외부 서비스에 스크로블 보내기"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "트랜스코딩",
|
||||
"name": "트랜스코딩 |||| 트랜스코딩들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"targetFormat": "대상 포맷",
|
||||
@@ -143,111 +143,111 @@
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "플레이리스트",
|
||||
"name": "재생목록 |||| 재생목록들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"duration": "시간",
|
||||
"duration": "지속",
|
||||
"ownerName": "소유자",
|
||||
"public": "공개",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"createdAt": "생성 날짜",
|
||||
"songCount": "곡",
|
||||
"comment": "코멘트",
|
||||
"sync": "자동 임포트",
|
||||
"path": "임포트 원본"
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"songCount": "노래",
|
||||
"comment": "댓글",
|
||||
"sync": "자동 가져오기",
|
||||
"path": "다음에서 가져오기"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "플레이리스트 선택",
|
||||
"addNewPlaylist": "'%{name}' 생성",
|
||||
"selectPlaylist": "재생목록 선택:",
|
||||
"addNewPlaylist": "\"%{name}\" 만들기",
|
||||
"export": "내보내기",
|
||||
"makePublic": "공개하기",
|
||||
"makePrivate": "비공개로 전환하기"
|
||||
"makePublic": "공개",
|
||||
"makePrivate": "비공개"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "중복된 곡 추가",
|
||||
"song_exist": "이미 플레이리스트에 존재하는 곡입니다. 추가하시겠습니까?"
|
||||
"duplicate_song": "중복된 노래 추가",
|
||||
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "라디오",
|
||||
"name": "라디오 |||| 라디오들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"streamUrl": "스트리밍 URL",
|
||||
"homePageUrl": "홈페이지 URL",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"createdAt": "생성 날짜"
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "바로 재생"
|
||||
"playNow": "지금 재생"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "공유",
|
||||
"name": "공유 |||| 공유되는 것들",
|
||||
"fields": {
|
||||
"username": "공유자",
|
||||
"username": "공유됨",
|
||||
"url": "URL",
|
||||
"description": "설명",
|
||||
"contents": "컨텐츠",
|
||||
"expiresAt": "만료 날짜",
|
||||
"lastVisitedAt": "최근 방문",
|
||||
"expiresAt": "만료",
|
||||
"lastVisitedAt": "마지막 방문",
|
||||
"visitCount": "방문 수",
|
||||
"format": "포맷",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"createdAt": "생성 날짜",
|
||||
"downloadable": ""
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"downloadable": "다운로드를 허용할까요?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Navidrome을 설치해 주셔서 감사합니다!",
|
||||
"welcome2": "관리자 사용자를 생성하고 시작해 보세요",
|
||||
"welcome2": "관리자를 만들고 시작해 보세요",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"buttonCreateAdmin": "관리자 생성",
|
||||
"auth_check_error": "인증에 실패했습니다. 다시 로그인하세요",
|
||||
"user_menu": "프로필",
|
||||
"username": "사용자명",
|
||||
"buttonCreateAdmin": "관리자 만들기",
|
||||
"auth_check_error": "계속하려면 로그인하세요",
|
||||
"user_menu": "프로파일",
|
||||
"username": "사용자이름",
|
||||
"password": "비밀번호",
|
||||
"sign_in": "로그인",
|
||||
"sign_in_error": "인증에 실패했습니다. 입력값을 확인하세요",
|
||||
"sign_in": "가입",
|
||||
"sign_in_error": "인증에 실패했습니다. 다시 시도하세요",
|
||||
"logout": "로그아웃"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "문자와 숫자만 사용하세요",
|
||||
"passwordDoesNotMatch": "비밀번호가 일치하지 않습니다",
|
||||
"required": "필수 항목입니다",
|
||||
"minLength": "%{min}자 이상이어야 합니다",
|
||||
"maxLength": "%{max}자 이하이어야 합니다",
|
||||
"minValue": "%{min} 이상이어야 합니다",
|
||||
"maxValue": "%{max} 이하이어야 합니다",
|
||||
"number": "숫자여야 합니다",
|
||||
"email": "유효한 이메일 주소여야 합니다",
|
||||
"oneOf": "다음 중 하나여야 합니다: %{options}",
|
||||
"regex": "다음과 같은 형식이어야 합니다: %{pattern}",
|
||||
"unique": "고유해야 합니다",
|
||||
"url": "유효한 URL을 입력하세요"
|
||||
"passwordDoesNotMatch": "비밀번호가 일치하지 않음",
|
||||
"required": "필수 항목임",
|
||||
"minLength": "%{min}자 이하여야 함",
|
||||
"maxLength": "%{max}자 이하여야 함",
|
||||
"minValue": "%{min}자 이상이어야 함",
|
||||
"maxValue": "%{max}자 이하여야 함",
|
||||
"number": "숫자여야 함",
|
||||
"email": "유효한 이메일이어야 함",
|
||||
"oneOf": "다음 중 하나여야 함: %{options}",
|
||||
"regex": "특정 형식(정규식)과 일치해야 함: %{pattern}",
|
||||
"unique": "고유해야 함",
|
||||
"url": "유효한 URL이어야 함"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "필터 추가",
|
||||
"add": "추가",
|
||||
"back": "뒤로",
|
||||
"bulk_actions": "%{smart_count}개 선택",
|
||||
"back": "뒤로 가기",
|
||||
"bulk_actions": "1 개 항목이 선택되었음 |||| %{smart_count} 개 항목이 선택되었음",
|
||||
"cancel": "취소",
|
||||
"clear_input_value": "비우기",
|
||||
"clear_input_value": "값 지우기",
|
||||
"clone": "복제",
|
||||
"confirm": "확인",
|
||||
"create": "생성",
|
||||
"create": "만들기",
|
||||
"delete": "삭제",
|
||||
"edit": "편집",
|
||||
"export": "내보내기",
|
||||
"list": "목록",
|
||||
"refresh": "새로고침",
|
||||
"remove_filter": "필터 삭제",
|
||||
"remove": "삭제",
|
||||
"refresh": "새로 고침",
|
||||
"remove_filter": "이 필터 제거",
|
||||
"remove": "제거",
|
||||
"save": "저장",
|
||||
"search": "검색",
|
||||
"show": "상세 정보",
|
||||
"show": "표시",
|
||||
"sort": "정렬",
|
||||
"undo": "실행 취소",
|
||||
"expand": "확장",
|
||||
@@ -255,7 +255,7 @@
|
||||
"open_menu": "메뉴 열기",
|
||||
"close_menu": "메뉴 닫기",
|
||||
"unselect": "선택 해제",
|
||||
"skip": "스킵",
|
||||
"skip": "건너뛰기",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "공유",
|
||||
"download": "다운로드"
|
||||
@@ -265,115 +265,115 @@
|
||||
"false": "아니요"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} 생성",
|
||||
"create": "%{name} 만들기",
|
||||
"dashboard": "대시보드",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "문제가 발생했습니다",
|
||||
"error": "문제가 발생하였음",
|
||||
"list": "%{name}",
|
||||
"loading": "로딩 중입니다. 잠시 기다려주세요",
|
||||
"not_found": "찾을 수 없습니다",
|
||||
"loading": "로딩 중",
|
||||
"not_found": "찾을 수 없음",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "%{name}이(가) 없습니다",
|
||||
"invite": "생성하시겠습니까?"
|
||||
"empty": "아직 %{name}이(가) 없습니다.",
|
||||
"invite": "추가할까요?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "파일을 끌어 놓거나 클릭하여 업로드하세요",
|
||||
"upload_single": "파일을 끌어 놓거나 클릭하여 업로드하세요"
|
||||
"upload_several": "업로드할 파일을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 파일을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "이미지를 끌어 놓거나 클릭하여 업로드하세요",
|
||||
"upload_single": "이미지를 끌어 놓거나 클릭하여 업로드하세요"
|
||||
"upload_several": "업로드할 사진을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 사진을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "사용 가능한 데이터가 없습니다",
|
||||
"many_missing": "선택한 데이터 중 일부가 사용 가능하지 않습니다",
|
||||
"single_missing": "선택한 데이터가 사용 가능하지 않습니다"
|
||||
"all_missing": "참조 데이터를 찾을 수 없습니다.",
|
||||
"many_missing": "연관된 참조 중 적어도 하나는 더 이상 사용할 수 없는 것 같습니다.",
|
||||
"single_missing": "연관된 참조는 더 이상 사용할 수 없는 것 같습니다."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "숨기기",
|
||||
"toggle_hidden": "보이기"
|
||||
"toggle_visible": "비밀번호 숨기기",
|
||||
"toggle_hidden": "비밀번호 표시"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "정보",
|
||||
"are_you_sure": "정말로 이 작업을 수행하시겠습니까?",
|
||||
"bulk_delete_content": "%{name}을(를) 삭제하시겠습니까? |||| %{smart_count}개의 항목을 삭제하시겠습니까?",
|
||||
"bulk_delete_title": "%{name} 삭제 |||| %{name} %{smart_count}개 삭제",
|
||||
"delete_content": "삭제하시겠습니까?",
|
||||
"are_you_sure": "확실한가요?",
|
||||
"bulk_delete_content": "이 %{name}을(를) 삭제할까요? |||| 이 %{smart_count} 개의 항목을 삭제할까요?",
|
||||
"bulk_delete_title": "%{name} 삭제 |||| %{smart_count} %{name} 삭제",
|
||||
"delete_content": "이 항목을 삭제할까요?",
|
||||
"delete_title": "%{name} #%{id} 삭제",
|
||||
"details": "세부 정보",
|
||||
"error": "클라이언트 오류로 처리를 완료할 수 없습니다",
|
||||
"invalid_form": "입력값에 오류가 있습니다. 오류 메시지를 확인하세요",
|
||||
"loading": "로딩 중입니다. 잠시만 기다려주세요",
|
||||
"details": "상세 정보",
|
||||
"error": "클라이언트 오류가 발생하여 요청을 완료할 수 없습니다.",
|
||||
"invalid_form": "양식이 유효하지 않습니다. 오류를 확인하세요",
|
||||
"loading": "페이지가 로드 중입니다. 잠시만 기다려 주세요",
|
||||
"no": "아니요",
|
||||
"not_found": "잘못된 URL을 입력하거나 잘못된 링크를 따라갔습니다",
|
||||
"not_found": "잘못된 URL을 입력했거나 잘못된 링크를 클릭했습니다.",
|
||||
"yes": "예",
|
||||
"unsaved_changes": "변경 사항이 저장되지 않았습니다. 이 페이지를 떠나시겠습니까?"
|
||||
"unsaved_changes": "일부 변경 사항이 저장되지 않았습니다. 무시할까요?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "결과가 없습니다",
|
||||
"no_more_results": "페이지 %{page}는 최대 페이지 수를 초과했습니다. 이전 페이지로 돌아가세요",
|
||||
"page_out_of_boundaries": "페이지 %{page}는 최대 페이지 수를 초과했습니다",
|
||||
"page_out_from_end": "마지막 페이지 이후로 이동할 수 없습니다",
|
||||
"page_out_from_begin": "첫 페이지 이전으로 이동할 수 없습니다",
|
||||
"no_results": "결과를 찾을 수 없음",
|
||||
"no_more_results": "페이지 번호 %{page}이(가) 경계를 벗어났습니다. 이전 페이지를 시도해 보세요.",
|
||||
"page_out_of_boundaries": "페이지 번호 %{page}이(가) 경계를 벗어남",
|
||||
"page_out_from_end": "마지막 페이지 뒤로 갈 수 없음",
|
||||
"page_out_from_begin": "첫 페이지 앞으로 갈 수 없음",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
|
||||
"page_rows_per_page": "페이지당 항목 수:",
|
||||
"page_rows_per_page": "페이지당 항목:",
|
||||
"next": "다음",
|
||||
"prev": "이전",
|
||||
"skip_nav": "메뉴 건너뛰기"
|
||||
"skip_nav": "콘텐츠 건너뛰기"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "업데이트되었습니다 |||| %{smart_count}개 업데이트되었습니다",
|
||||
"created": "생성되었습니다",
|
||||
"deleted": "삭제되었습니다 |||| %{smart_count}개 삭제되었습니다",
|
||||
"bad_item": "잘못된 항목입니다",
|
||||
"item_doesnt_exist": "항목이 존재하지 않습니다",
|
||||
"http_error": "통신 오류가 발생했습니다",
|
||||
"data_provider_error": "dataProvider 오류입니다. 자세한 내용은 콘솔을 확인하세요",
|
||||
"i18n_error": "번역 파일을 로드할 수 없습니다",
|
||||
"canceled": "취소되었습니다",
|
||||
"logged_out": "인증에 실패했습니다. 다시 로그인하세요",
|
||||
"new_version": "새로운 버전이 사용 가능합니다! 페이지를 새로 고쳐주세요."
|
||||
"updated": "요소 업데이트됨 |||| %{smart_count} 개 요소 업데이트됨",
|
||||
"created": "요소 생성됨",
|
||||
"deleted": "요소 삭제됨 |||| %{smart_count} 개 요소 삭제됨",
|
||||
"bad_item": "잘못된 요소",
|
||||
"item_doesnt_exist": "요소가 존재하지 않음",
|
||||
"http_error": "서버 통신 오류",
|
||||
"data_provider_error": "dataProvider 오류입니다. 자세한 내용은 콘솔을 확인하세요.",
|
||||
"i18n_error": "지정된 언어에 대한 번역을 로드할 수 없음",
|
||||
"canceled": "작업이 취소됨",
|
||||
"logged_out": "세션이 종료되었습니다. 다시 연결하세요.",
|
||||
"new_version": "새로운 버전이 출시되었습니다! 이 창을 새로 고침하세요."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "표시 열",
|
||||
"columnsToDisplay": "표시할 열",
|
||||
"layout": "레이아웃",
|
||||
"grid": "그리드",
|
||||
"table": "테이블"
|
||||
"grid": "격자",
|
||||
"table": "표"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "주의",
|
||||
"transcodingDisabled": "보안상의 이유로 웹 인터페이스에서 트랜스코드 설정이 비활성화되어 있습니다.\n이를 설정하려면 환경 변수 %{config}를 설정하고 서버를 재시작하십시오.",
|
||||
"transcodingEnabled": "Navidrome은 현재 %{config} 설정으로 실행되며, 웹 인터페이스의 트랜스코드 설정에 따라 명령을 실행할 수 있습니다.\n보안상의 이유로 이 설정은 트랜스코드 설정을 변경할 때만 활성화하는 것을 권장합니다.",
|
||||
"songsAddedToPlaylist": "플레이리스트에 1곡 추가되었습니다 |||| 플레이리스트에 %{smart_count}곡 추가되었습니다",
|
||||
"noPlaylistsAvailable": "사용 가능하지 않음",
|
||||
"delete_user_title": "'%{name}' 삭제",
|
||||
"delete_user_content": "이 사용자와 그의 모든 데이터(플레이리스트 및 설정 등)를 삭제하시겠습니까?",
|
||||
"notifications_blocked": "브라우저의 설정으로 이 사이트의 알림이 차단되어 있습니다",
|
||||
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않습니다",
|
||||
"lastfmLinkSuccess": "Last.fm과 연결되어 scrobble이 활성화되었습니다",
|
||||
"lastfmLinkFailure": "Last.fm과 연결할 수 없습니다",
|
||||
"lastfmUnlinkSuccess": "설정이 해제되어 Last.fm으로의 scrobble이 비활성화되었습니다",
|
||||
"lastfmUnlinkFailure": "Last.fm과 연결 해제를 실패했습니다",
|
||||
"note": "참고",
|
||||
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
|
||||
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
|
||||
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
|
||||
"noPlaylistsAvailable": "사용 가능한 노래 없음",
|
||||
"delete_user_title": "사용자 '%{name}' 삭제",
|
||||
"delete_user_content": "이 사용자와 (재생목록 및 기본 설정 포함된) 모든 데이터를 삭제할까요?",
|
||||
"notifications_blocked": "탐색기 설정에서 이 사이트의 알림을 차단하였음",
|
||||
"notifications_not_available": "이 탐색기는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하지 않음",
|
||||
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음",
|
||||
"lastfmLinkFailure": "Last.fm을 연결할 수 없음",
|
||||
"lastfmUnlinkSuccess": "Last.fm이 연결 해제되었고 스크로블링이 비활성화되었음",
|
||||
"lastfmUnlinkFailure": "Last.fm을 연결 해제할 수 없음",
|
||||
"openIn": {
|
||||
"lastfm": "Last.fm에서 열기",
|
||||
"musicbrainz": "MusicBrainz에서 열기"
|
||||
},
|
||||
"lastfmLink": "계속 읽기",
|
||||
"listenBrainzLinkSuccess": "%{user}에 대한 scrobbling 설정이 성공적으로 완료되었습니다",
|
||||
"listenBrainzLinkFailure": "ListenBrainz와 연결에 실패했습니다: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz와의 연결과 scrobbling이 비활성화되었습니다",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz와의 연결 해제를 실패했습니다",
|
||||
"downloadOriginalFormat": "원본 형식으로 다운로드",
|
||||
"shareOriginalFormat": "원본 형식으로 공유",
|
||||
"lastfmLink": "더 읽기...",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz가 성공적으로 연결되었고 스크로블링이 사용자로 활성화되었음: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz를 연결할 수 없음: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz가 연결 해제되었고 스크로블링이 비활성화되었음",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz를 연결 해제할 수 없음",
|
||||
"downloadOriginalFormat": "오리지널 형식으로 다운로드",
|
||||
"shareOriginalFormat": "오리지널 형식으로 공유",
|
||||
"shareDialogTitle": "%{resource} '%{name}' 공유",
|
||||
"shareBatchDialogTitle": "1 %{resource} 공유 |||| %{smart_count} %{resource} 공유",
|
||||
"shareSuccess": "복사되었습니다: %{url}",
|
||||
"shareFailure": "복사하지 못했습니다 %{url}",
|
||||
"downloadDialogTitle": "다운로드 %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": ""
|
||||
"shareSuccess": "URL이 클립보드에 복사됨: %{url}",
|
||||
"shareFailure": "%{url}을 클립보드에 복사하는 중 오류 발생",
|
||||
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
|
||||
"shareCopyToClipboard": "클립보드에 복사: Ctrl+C, Enter"
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
@@ -385,46 +385,46 @@
|
||||
"options": {
|
||||
"theme": "테마",
|
||||
"language": "언어",
|
||||
"defaultView": "기본 뷰",
|
||||
"defaultView": "기본 보기",
|
||||
"desktop_notifications": "데스크톱 알림",
|
||||
"lastfmScrobbling": "Last.fm으로 scrobble하기",
|
||||
"listenBrainzScrobbling": "ListenBrainz로 scrobble하기",
|
||||
"replaygain": "ReplayGain 모드",
|
||||
"preAmp": "프리앰프",
|
||||
"lastfmScrobbling": "Last.fm으로 스크로블",
|
||||
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
|
||||
"replaygain": "리플레이게인 모드",
|
||||
"preAmp": "리플레이게인 프리앰프 (dB)",
|
||||
"gain": {
|
||||
"none": "비활성화",
|
||||
"album": "앨범 Gain 사용",
|
||||
"track": "트랙 Gain 사용"
|
||||
"album": "앨범 게인 사용",
|
||||
"track": "트랙 게인 사용"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "앨범",
|
||||
"about": "상세 정보",
|
||||
"playlists": "플레이리스트",
|
||||
"sharedPlaylists": "공유된 플레이리스트"
|
||||
"about": "정보",
|
||||
"playlists": "재생목록",
|
||||
"sharedPlaylists": "공유된 재생목록"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "재생 목록",
|
||||
"playListsText": "대기열 재생",
|
||||
"openText": "열기",
|
||||
"closeText": "닫기",
|
||||
"notContentText": "음악이 없습니다",
|
||||
"clickToPlayText": "클릭하여 재생",
|
||||
"clickToPauseText": "일시 정지",
|
||||
"nextTrackText": "다음 곡",
|
||||
"previousTrackText": "이전 곡",
|
||||
"reloadText": "새로 고침",
|
||||
"volumeText": "음량",
|
||||
"notContentText": "음악 없음",
|
||||
"clickToPlayText": "재생하려면 클릭",
|
||||
"clickToPauseText": "일시 중지하려면 클릭",
|
||||
"nextTrackText": "다음 트랙",
|
||||
"previousTrackText": "이전 트랙",
|
||||
"reloadText": "다시 로드하기",
|
||||
"volumeText": "볼륨",
|
||||
"toggleLyricText": "가사 전환",
|
||||
"toggleMiniModeText": "최소화",
|
||||
"destroyText": "삭제",
|
||||
"destroyText": "제거",
|
||||
"downloadText": "다운로드",
|
||||
"removeAudioListsText": "목록 비우기",
|
||||
"clickToDeleteText": "클릭하여 %{name} 삭제",
|
||||
"emptyLyricText": "가사가 없습니다",
|
||||
"removeAudioListsText": "오디오 목록 삭제",
|
||||
"clickToDeleteText": "%{name}을(를) 삭제하려면 클릭",
|
||||
"emptyLyricText": "가사 없음",
|
||||
"playModeText": {
|
||||
"order": "순서대로",
|
||||
"orderLoop": "반복",
|
||||
"singleLoop": "한 곡 반복",
|
||||
"singleLoop": "노래 하나 반복",
|
||||
"shufflePlay": "셔플"
|
||||
}
|
||||
},
|
||||
@@ -437,24 +437,24 @@
|
||||
},
|
||||
"activity": {
|
||||
"title": "활동",
|
||||
"totalScanned": "스캔된 폴더",
|
||||
"totalScanned": "스캔된 전체 폴더",
|
||||
"quickScan": "빠른 스캔",
|
||||
"fullScan": "전체 스캔",
|
||||
"serverUptime": "서버 가동 시간",
|
||||
"serverDown": "서버 오프라인"
|
||||
"serverDown": "오프라인"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 단축키",
|
||||
"hotkeys": {
|
||||
"show_help": "도움말 표시",
|
||||
"toggle_menu": "사이드바 표시/숨기기",
|
||||
"toggle_play": "재생/정지",
|
||||
"prev_song": "이전 곡",
|
||||
"next_song": "다음 곡",
|
||||
"vol_up": "음량 높이기",
|
||||
"vol_down": "음량 낮추기",
|
||||
"toggle_love": "별표 토글",
|
||||
"current_song": "현재 곡으로 이동"
|
||||
"show_help": "이 도움말 표시",
|
||||
"toggle_menu": "메뉴 사이드바 전환",
|
||||
"toggle_play": "재생 / 일시 중지",
|
||||
"prev_song": "이전 노래",
|
||||
"next_song": "다음 노래",
|
||||
"vol_up": "볼륨 높이기",
|
||||
"vol_down": "볼륨 낮추기",
|
||||
"toggle_love": "이 트랙을 즐겨찾기에 추가",
|
||||
"current_song": "현재 노래로 이동"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
466
resources/i18n/sr.json
Normal file
466
resources/i18n/sr.json
Normal file
@@ -0,0 +1,466 @@
|
||||
|
||||
{
|
||||
"languageName": "српски",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Песма |||| Песме",
|
||||
"fields": {
|
||||
"albumArtist": "Уметник албума",
|
||||
"duration": "Трајање",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Пуштано",
|
||||
"title": "Наслов",
|
||||
"artist": "Уметник",
|
||||
"album": "Албум",
|
||||
"path": "Путања фајла",
|
||||
"genre": "Жанр",
|
||||
"compilation": "Компилација",
|
||||
"year": "Година",
|
||||
"size": "Величина фајла",
|
||||
"updatedAt": "Ажурирано",
|
||||
"bitRate": "Битски проток",
|
||||
"channels": "Канала",
|
||||
"discSubtitle": "Поднаслов диска",
|
||||
"starred": "Омиљено",
|
||||
"comment": "Коментар",
|
||||
"rating": "Рејтинг",
|
||||
"quality": "Квалитет",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Последње пуштано",
|
||||
"createdAt": "Датум додавања"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Пусти касније",
|
||||
"playNow": "Пусти одмах",
|
||||
"addToPlaylist": "Додај у плејлисту",
|
||||
"shuffleAll": "Измешај све",
|
||||
"download": "Преузми",
|
||||
"playNext": "Пусти наредно",
|
||||
"info": "Прикажи инфо"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Албум |||| Албуми",
|
||||
"fields": {
|
||||
"albumArtist": "Уметник албума",
|
||||
"artist": "Уметник",
|
||||
"duration": "Трајање",
|
||||
"songCount": "Песме",
|
||||
"playCount": "Пуштано",
|
||||
"size": "Величина",
|
||||
"name": "Назив",
|
||||
"genre": "Жанр",
|
||||
"compilation": "Компилација",
|
||||
"year": "Година",
|
||||
"originalDate": "Оригинално",
|
||||
"releaseDate": "Објављено",
|
||||
"releases": "Издање|||| Издања",
|
||||
"released": "Објављено",
|
||||
"updatedAt": "Ажурирано",
|
||||
"comment": "Коментар",
|
||||
"rating": "Рејтинг",
|
||||
"createdAt": "Датум додавања"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Пусти",
|
||||
"playNext": "Пусти наредно",
|
||||
"addToQueue": "Пусти касније",
|
||||
"share": "Дели",
|
||||
"shuffle": "Измешај",
|
||||
"addToPlaylist": "Додај у плејлисту",
|
||||
"download": "Преузми",
|
||||
"info": "Прикажи инфо"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Све",
|
||||
"random": "Насумично",
|
||||
"recentlyAdded": "Додато недавно",
|
||||
"recentlyPlayed": "Пуштано недавно",
|
||||
"mostPlayed": "Најчешће пуштано",
|
||||
"starred": "Омиљено",
|
||||
"topRated": "Најбоље рангирано"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Уметник |||| Уметници",
|
||||
"fields": {
|
||||
"name": "Име",
|
||||
"albumCount": "Број албума",
|
||||
"songCount": "Број песама",
|
||||
"size": "Величина",
|
||||
"playCount": "Пуштано",
|
||||
"rating": "Рејтинг",
|
||||
"genre": "Жанр"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Корисник |||| Корисници",
|
||||
"fields": {
|
||||
"userName": "Корисничко име",
|
||||
"isAdmin": "Да ли је Админ",
|
||||
"lastLoginAt": "Последња пријава",
|
||||
"updatedAt": "Ажурирано",
|
||||
"name": "Име",
|
||||
"password": "Лозинка",
|
||||
"createdAt": "Креирана",
|
||||
"changePassword": "Измени лозинку?",
|
||||
"currentPassword": "Текућа лозинка",
|
||||
"newPassword": "Нова лозинка",
|
||||
"token": "Жетон"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Измене вашег имена ће постати видљиве након следеће пријаве"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Корисник креиран",
|
||||
"updated": "Корисник ажуриран",
|
||||
"deleted": "Корисник обрисан"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.",
|
||||
"clickHereForToken": "Кликните овде да преузмете свој жетон"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Плејер |||| Плејери",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"transcodingId": "Транскодирање",
|
||||
"maxBitRate": "Макс. битски проток",
|
||||
"client": "Клијент",
|
||||
"userName": "Корисничко име",
|
||||
"lastSeen": "последњи пут виђен",
|
||||
"reportRealPath": "Пријављуј реалну путању",
|
||||
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Транскодирање |||| Транскодирања",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"targetFormat": "Циљни формат",
|
||||
"defaultBitRate": "Подразумевани битски проток",
|
||||
"command": "Команда"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Плејлиста |||| Плејлисте",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"duration": "Трајање",
|
||||
"ownerName": "Власник",
|
||||
"public": "Јавна",
|
||||
"updatedAt": "Ажурирана",
|
||||
"createdAt": "Креирана",
|
||||
"songCount": "Песме",
|
||||
"comment": "Коментар",
|
||||
"sync": "Ауто-увоз",
|
||||
"path": "Увоз из"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Изабери плејлисту",
|
||||
"addNewPlaylist": "Креирај \"%{name}\"",
|
||||
"export": "Извоз",
|
||||
"makePublic": "Учини јавном",
|
||||
"makePrivate": "Учини приватном"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Додај дуплиране песме",
|
||||
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Радио |||| Радији",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"streamUrl": "URL тока",
|
||||
"homePageUrl": "URL почетне странице",
|
||||
"updatedAt": "Ажурирано",
|
||||
"createdAt": "Креирано"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Пусти одмах"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Дељење |||| Дељења",
|
||||
"fields": {
|
||||
"username": "Поделио",
|
||||
"url": "URL",
|
||||
"description": "Опис",
|
||||
"downloadable": "Допушта се преузимање?",
|
||||
"contents": "Садржај",
|
||||
"expiresAt": "Истиче",
|
||||
"lastVisitedAt": "Последњи пут посећено",
|
||||
"visitCount": "Број посета",
|
||||
"format": "Формат",
|
||||
"maxBitRate": "Макс. битски проток",
|
||||
"updatedAt": "Ажурирано",
|
||||
"createdAt": "Креирано"
|
||||
},
|
||||
"notifications": {
|
||||
},
|
||||
"actions": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Хвала што сте инсталирали Navidrome!",
|
||||
"welcome2": "За почетак, креирајте админ корисника",
|
||||
"confirmPassword": "Потврдите лозинку",
|
||||
"buttonCreateAdmin": "Креирај админа",
|
||||
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
|
||||
"user_menu": "Профил",
|
||||
"username": "Корисничко име",
|
||||
"password": "Лозинка",
|
||||
"sign_in": "Пријави се",
|
||||
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
|
||||
"logout": "Одјави се"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Молимо вас да користите само слова и цифре",
|
||||
"passwordDoesNotMatch": "Лозинка се не подудара",
|
||||
"required": "Неопходно",
|
||||
"minLength": "Мора да буде барем %{min} карактера",
|
||||
"maxLength": "Мора да буде %{max} карактера или мање",
|
||||
"minValue": "Мора да буде барем %{min}",
|
||||
"maxValue": "Мора да буде %{max} или мање",
|
||||
"number": "Мора да буде број",
|
||||
"email": "Мора да буде исправна и-мејл адреса",
|
||||
"oneOf": "Мора да буде једно од: %{options}",
|
||||
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
|
||||
"unique": "Мора да буде јединствено",
|
||||
"url": "Мора да буде исправна URL адреса"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Додај филтер",
|
||||
"add": "Додај",
|
||||
"back": "Иди назад",
|
||||
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Откажи",
|
||||
"clear_input_value": "Обриши вредност",
|
||||
"clone": "Клонирај",
|
||||
"confirm": "Потврди",
|
||||
"create": "Креирај",
|
||||
"delete": "Обриши",
|
||||
"edit": "Уреди",
|
||||
"export": "Извези",
|
||||
"list": "Листа",
|
||||
"refresh": "Освежи",
|
||||
"remove_filter": "Уклони овај филтер",
|
||||
"remove": "Уклони",
|
||||
"save": "Сачувај",
|
||||
"search": "Тражи",
|
||||
"show": "Прикажи",
|
||||
"sort": "Сортирај",
|
||||
"undo": "Поништи",
|
||||
"expand": "Развиј",
|
||||
"close": "Затвори",
|
||||
"open_menu": "Отвори мени",
|
||||
"close_menu": "Затвори мени",
|
||||
"unselect": "Уклони избор",
|
||||
"skip": "Прескочи",
|
||||
"share": "Подели",
|
||||
"download": "Преузми"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Да",
|
||||
"false": "Не"
|
||||
},
|
||||
"page": {
|
||||
"create": "Креирај %{name}",
|
||||
"dashboard": "Контролна табла",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Нешто је пошло наопако",
|
||||
"list": "%{name}",
|
||||
"loading": "Учитава се",
|
||||
"not_found": "Није пронађено",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Још увек нема %{name}.",
|
||||
"invite": "Желите ли да се дода?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
|
||||
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
|
||||
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Не могу да се нађу подаци референци.",
|
||||
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
|
||||
"single_missing": "Изгледа да придружена референца више није доступна."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Сакриј лозинку",
|
||||
"toggle_hidden": "Прикажи лозинку"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "О програму",
|
||||
"are_you_sure": "Да ли сте сигурни?",
|
||||
"bulk_delete_content": "Да ли заиста желите да обришете %{name}? |||| Да ли заиста желите да обришете %{smart_count} ставке?",
|
||||
"bulk_delete_title": "Брисање %{name} |||| Брисање %{smart_count} %{name}",
|
||||
"delete_content": "Да ли заиста желите да обришете ову ставку?",
|
||||
"delete_title": "Брисање %{name} #%{id}",
|
||||
"details": "Детаљи",
|
||||
"error": "Дошло је до клијентске грешке и ваш захтев није могао да се изврши.",
|
||||
"invalid_form": "Формулар није исправан. Молимо вас да исправите грешке",
|
||||
"loading": "Страница се учитава, сачекајте мало",
|
||||
"no": "Не",
|
||||
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
|
||||
"yes": "Да",
|
||||
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Није пронађен ниједан резултат",
|
||||
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
|
||||
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
|
||||
"page_out_from_end": "Не може да се иде након последње странице",
|
||||
"page_out_from_begin": "Не може да се иде испред странице 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
|
||||
"page_rows_per_page": "Ставки по страници:",
|
||||
"next": "Наредна",
|
||||
"prev": "Претход",
|
||||
"skip_nav": "Прескочи на садржај"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано",
|
||||
"created": "Елемент је креиран",
|
||||
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
|
||||
"bad_item": "Неисправни елемент",
|
||||
"item_doesnt_exist": "Елемент не постоји",
|
||||
"http_error": "Грешка у комуникацији са сервером",
|
||||
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
|
||||
"i18n_error": "Не могу да се учитају преводи за наведени језик",
|
||||
"canceled": "Акција је отказана",
|
||||
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
|
||||
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Колоне за приказ",
|
||||
"layout": "Распоред",
|
||||
"grid": "Мрежа",
|
||||
"table": "Табела"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "НАПОМЕНА",
|
||||
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
|
||||
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.",
|
||||
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
|
||||
"noPlaylistsAvailable": "Није доступна ниједна",
|
||||
"delete_user_title": "Брисање корисника ’%{name}’",
|
||||
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
|
||||
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
|
||||
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
|
||||
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
|
||||
"lastfmLinkFailure": "Last.fm није могао да се повеже",
|
||||
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
|
||||
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
|
||||
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
|
||||
"openIn": {
|
||||
"lastfm": "Отвори у Last.fm",
|
||||
"musicbrainz": "Отвори у MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Прочитај још...",
|
||||
"shareOriginalFormat": "Подели у оригиналном формату",
|
||||
"shareDialogTitle": "Подели %{resource} ’%{name}’",
|
||||
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
|
||||
"shareSuccess": "URL је копиран у клипборд: %{url}",
|
||||
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
|
||||
"downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})",
|
||||
"downloadOriginalFormat": "Преузми у оригиналном формату"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Библиотека",
|
||||
"settings": "Подешавања",
|
||||
"version": "Верзија",
|
||||
"theme": "Тема",
|
||||
"personal": {
|
||||
"name": "Лична",
|
||||
"options": {
|
||||
"theme": "Тема",
|
||||
"language": "Језик",
|
||||
"defaultView": "Подразумевани поглед",
|
||||
"desktop_notifications": "Десктоп обавештења",
|
||||
"lastfmScrobbling": "Скроблуј на Last.fm",
|
||||
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
|
||||
"replaygain": "ReplayGain режим",
|
||||
"preAmp": "ReplayGain претпојачање (dB)",
|
||||
"gain": {
|
||||
"none": "ИскљученоDisabled",
|
||||
"album": "Користи Album појачање",
|
||||
"track": "Користи Track појачање"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Албуми",
|
||||
"playlists": "Плејлисте",
|
||||
"sharedPlaylists": "Дељене плејлисте",
|
||||
"about": "О"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Ред за пуштање",
|
||||
"openText": "Отвори",
|
||||
"closeText": "Затвори",
|
||||
"notContentText": "Нема музике",
|
||||
"clickToPlayText": "Кликни за пуштање",
|
||||
"clickToPauseText": "Кликни за паузирање",
|
||||
"nextTrackText": "Наредна нумера",
|
||||
"previousTrackText": "Претходна нумера",
|
||||
"reloadText": "Поново учитај",
|
||||
"volumeText": "Јачина",
|
||||
"toggleLyricText": "Укљ./Искљ. стихове",
|
||||
"toggleMiniModeText": "Умањи",
|
||||
"destroyText": "Уништи",
|
||||
"downloadText": "Преузми",
|
||||
"removeAudioListsText": "Обриши аудио листе",
|
||||
"clickToDeleteText": "Кликните да обришете %{name}",
|
||||
"emptyLyricText": "Нема стихова",
|
||||
"playModeText": {
|
||||
"order": "По редоследу",
|
||||
"orderLoop": "Понови",
|
||||
"singleLoop": "Понови једну",
|
||||
"shufflePlay": "Промешано"
|
||||
}
|
||||
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Почетна страница",
|
||||
"source": "Изворни кôд",
|
||||
"featureRequests": "Захтеви за функцијама"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Активност",
|
||||
"totalScanned": "Укупан број скенираних фолдера",
|
||||
"quickScan": "Брзо скенирање",
|
||||
"fullScan": "Комплетно скенирање",
|
||||
"serverUptime": "Сервер се извршава",
|
||||
"serverDown": "ВАН МРЕЖЕ"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome пречице",
|
||||
"hotkeys": {
|
||||
"show_help": "Прикажи ову помоћ",
|
||||
"toggle_menu": "Укљ./Искљ. бочну траку менија",
|
||||
"toggle_play": "Пусти / Паузирај",
|
||||
"prev_song": "Претходна песма",
|
||||
"next_song": "Наредна песма",
|
||||
"current_song": "Иди на текућу песму",
|
||||
"vol_up": "Појачај",
|
||||
"vol_down": "Утишај",
|
||||
"toggle_love": "Додај ову нумеру у омиљене"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,460 +1,462 @@
|
||||
{
|
||||
"languageName": "Svenska",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Låt |||| Låtar",
|
||||
"fields": {
|
||||
"albumArtist": "Album artist",
|
||||
"duration": "Längd",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Spelad",
|
||||
"title": "Titel",
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"path": "Sökväg",
|
||||
"genre": "Genre",
|
||||
"compilation": "Samling",
|
||||
"year": "År",
|
||||
"size": "Filstorlek",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"bitRate": "Bitrate",
|
||||
"discSubtitle": "Underrubrik",
|
||||
"starred": "Favorit",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Betyg",
|
||||
"quality": "Kvalite",
|
||||
"bpm": "BPM",
|
||||
"playDate": "",
|
||||
"channels": "",
|
||||
"createdAt": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lägg till kön",
|
||||
"playNow": "Spela",
|
||||
"addToPlaylist": "Lägg till i spellista",
|
||||
"shuffleAll": "Blanda",
|
||||
"download": "Hämta",
|
||||
"playNext": "Spela nästa",
|
||||
"info": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Album",
|
||||
"fields": {
|
||||
"albumArtist": "Album artist",
|
||||
"artist": "Artist",
|
||||
"duration": "Längd",
|
||||
"songCount": "Låtar",
|
||||
"playCount": "Spelad",
|
||||
"name": "Namn",
|
||||
"genre": "Genre",
|
||||
"compilation": "Samling",
|
||||
"year": "År",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Betyg",
|
||||
"createdAt": "",
|
||||
"size": "",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Spela",
|
||||
"playNext": "Spela härnäst",
|
||||
"addToQueue": "Lägg till i kön",
|
||||
"shuffle": "Blanda",
|
||||
"addToPlaylist": "Lägg till i spellista",
|
||||
"download": "Hämta",
|
||||
"info": "",
|
||||
"share": ""
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alla låtar",
|
||||
"random": "Blanda",
|
||||
"recentlyAdded": "Senast tillagda",
|
||||
"recentlyPlayed": "Senast spelat",
|
||||
"mostPlayed": "Mest spelat",
|
||||
"starred": "Favoriter",
|
||||
"topRated": "Betygsatta"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artist |||| Artister",
|
||||
"fields": {
|
||||
"name": "Namn",
|
||||
"albumCount": "Antal album",
|
||||
"songCount": "Antal låtar",
|
||||
"playCount": "Spelade",
|
||||
"rating": "Betygsatt",
|
||||
"genre": "Genre",
|
||||
"size": ""
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Användare |||| Användare",
|
||||
"fields": {
|
||||
"userName": "Användarnamn",
|
||||
"isAdmin": "Administratör",
|
||||
"lastLoginAt": "Senaste inloggning",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"name": "Namn",
|
||||
"password": "Lösenord",
|
||||
"createdAt": "Skapad",
|
||||
"changePassword": "Byta lösenord?",
|
||||
"currentPassword": "Nuvarande lösenord",
|
||||
"newPassword": "Nytt lösenord",
|
||||
"token": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Namnändringar återspeglas vid nästkommande inloggning"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Användare har skapats",
|
||||
"updated": "Användare har uppdaterats",
|
||||
"deleted": "Användare har tagits bort"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "",
|
||||
"clickHereForToken": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Avkodare |||| Avkodare",
|
||||
"fields": {
|
||||
"name": "Namn",
|
||||
"transcodingId": "Omkodning",
|
||||
"maxBitRate": "Max. bitrate",
|
||||
"client": "Klient",
|
||||
"userName": "Användarnamn",
|
||||
"lastSeen": "Senast sedd",
|
||||
"reportRealPath": "Visa hela sökvägen",
|
||||
"scrobbleEnabled": ""
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Omkodning |||| Omkodningar",
|
||||
"fields": {
|
||||
"name": "Namn",
|
||||
"targetFormat": "Målformat",
|
||||
"defaultBitRate": "Standard bitrate",
|
||||
"command": "Kommando"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Spellista |||| Spellistor",
|
||||
"fields": {
|
||||
"name": "Namn",
|
||||
"duration": "Längd",
|
||||
"ownerName": "Ägare",
|
||||
"public": "Offentlig",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"createdAt": "Skapad",
|
||||
"songCount": "Låt",
|
||||
"comment": "Kommentar",
|
||||
"sync": "Synkronisera",
|
||||
"path": "Importerat från"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Välj en spellista:",
|
||||
"addNewPlaylist": "Lägg till \"%{name}\"",
|
||||
"export": "Exportera",
|
||||
"makePublic": "",
|
||||
"makePrivate": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Lägg till dubletter",
|
||||
"song_exist": "Det finns dubletter som är på väg att läggas till i spellistan. Vill du lägga till dem ändå?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"streamUrl": "",
|
||||
"homePageUrl": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": ""
|
||||
},
|
||||
"actions": {
|
||||
"playNow": ""
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"username": "",
|
||||
"url": "",
|
||||
"description": "",
|
||||
"contents": "",
|
||||
"expiresAt": "",
|
||||
"lastVisitedAt": "",
|
||||
"visitCount": "",
|
||||
"format": "",
|
||||
"maxBitRate": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"downloadable": ""
|
||||
}
|
||||
}
|
||||
"languageName": "Svenska",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Låt |||| Låtar",
|
||||
"fields": {
|
||||
"albumArtist": "Albumartist",
|
||||
"duration": "Längd",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Spelningar",
|
||||
"title": "Titel",
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"path": "Sökväg",
|
||||
"genre": "Genre",
|
||||
"compilation": "Samling",
|
||||
"year": "År",
|
||||
"size": "Filstorlek",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"bitRate": "Bitrate",
|
||||
"channels": "Channels",
|
||||
"discSubtitle": "Underrubrik",
|
||||
"starred": "Favorit",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Betyg",
|
||||
"quality": "Kvalitet",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Senast spelad",
|
||||
"createdAt": "Skapad"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lägg till i kön",
|
||||
"playNow": "Spela nu",
|
||||
"addToPlaylist": "Lägg till i spellista",
|
||||
"shuffleAll": "Shuffle",
|
||||
"download": "Ladda ner",
|
||||
"playNext": "Spela nästa",
|
||||
"info": "Mer information"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Tack för att du installerade Navidrome!",
|
||||
"welcome2": "Lägg till en administratör för att börja",
|
||||
"confirmPassword": "Bekräfta lösenord",
|
||||
"buttonCreateAdmin": "Lägg till administratör",
|
||||
"auth_check_error": "Var god logga in för att fortsätta",
|
||||
"user_menu": "Profil",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"sign_in": "Logga in",
|
||||
"sign_in_error": "Felaktig inloggning, försök igen",
|
||||
"logout": "Logga ut"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Ogiltiga tecken, du kan endast använda bokstäver och siffror",
|
||||
"passwordDoesNotMatch": "Lösenorden matchar ej",
|
||||
"required": "Krävs",
|
||||
"minLength": "Mindst %{min} tecken",
|
||||
"maxLength": "Max %{max} tecken",
|
||||
"minValue": "Minst %{min}",
|
||||
"maxValue": "Max %{max}",
|
||||
"number": "Måste vara ett nummer",
|
||||
"email": "Ange giltig e-post adress",
|
||||
"oneOf": "Måste vara en av: %{options}",
|
||||
"regex": "Måste matcha ett förvalt format (regexp): %{pattern}",
|
||||
"unique": "Måste vara unik",
|
||||
"url": ""
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Lägg till filter",
|
||||
"add": "Lägg till",
|
||||
"back": "Tillbaka",
|
||||
"bulk_actions": "%{smart_count} valda",
|
||||
"cancel": "Avbryt",
|
||||
"clear_input_value": "Rensa",
|
||||
"clone": "Klona",
|
||||
"confirm": "Bekräfta",
|
||||
"create": "Lägg till",
|
||||
"delete": "Ta bort",
|
||||
"edit": "Redigera",
|
||||
"export": "Exportera",
|
||||
"list": "Lista",
|
||||
"refresh": "Uppdatera",
|
||||
"remove_filter": "Ta bort filter",
|
||||
"remove": "Ta bort",
|
||||
"save": "Spara",
|
||||
"search": "Sök",
|
||||
"show": "Visa",
|
||||
"sort": "Sortera",
|
||||
"undo": "Ångra",
|
||||
"expand": "Expandera",
|
||||
"close": "Lås",
|
||||
"open_menu": "Öppna meny",
|
||||
"close_menu": "Stäng meny",
|
||||
"unselect": "Avmarkera",
|
||||
"skip": "Skippa",
|
||||
"bulk_actions_mobile": "",
|
||||
"share": "",
|
||||
"download": ""
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ja",
|
||||
"false": "Nej"
|
||||
},
|
||||
"page": {
|
||||
"create": "Lägg till %{name}",
|
||||
"dashboard": "Dashboard",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Ett fel uppstod",
|
||||
"list": "%{name} lista",
|
||||
"loading": "Bearbetar",
|
||||
"not_found": "Hittade inget",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Ingen %{name} ännu",
|
||||
"invite": "Vill du lägga till en?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Dra och släpp filer som ska laddas upp eller klicka för att markera filer.",
|
||||
"upload_single": "Dra och släpp en fil som ska laddas upp eller klicka för att markera en fil."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Dra och släpp filer som ska laddas upp eller klicka för att markera filer.",
|
||||
"upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att markera en fil."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Det går inte att hitta några referensdata.",
|
||||
"many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.",
|
||||
"single_missing": "Associerade referenser verkar inte längre vara tillgängliga."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Dölj lösenord",
|
||||
"toggle_hidden": "Visa lösenord"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Om",
|
||||
"are_you_sure": "Är du säker?",
|
||||
"bulk_delete_content": "Vill du ta bort %{name}? |||| Är du säker på att du vill ta bort %{smart_count} ?",
|
||||
"bulk_delete_title": "Ta bort %{name} |||| Tar bort %{smart_count} %{name}",
|
||||
"delete_content": "Är du säker du vill ta bort den här posten?",
|
||||
"delete_title": "Ta bort %{name} #%{id}",
|
||||
"details": "Detaljer",
|
||||
"error": "Ett klientfel uppstod och begäran kunde inte slutföras.",
|
||||
"invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel",
|
||||
"loading": "Sidan läses in, vad god vänta",
|
||||
"no": "Nej",
|
||||
"not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.",
|
||||
"yes": "Ja",
|
||||
"unsaved_changes": "Du har osparade ändringar. Ignorera dem?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Inga resultat hittades",
|
||||
"no_more_results": "Sidnumret %{page} existerar inte. Gå tillbaka till föregående sida.",
|
||||
"page_out_of_boundaries": "Sidnumret %{page} existerar inte",
|
||||
"page_out_from_end": "Finns inga fler sidor",
|
||||
"page_out_from_begin": "Det finns ingen sida före sidan 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
|
||||
"page_rows_per_page": "Antal per sida:",
|
||||
"next": "Nästa",
|
||||
"prev": "Föregående",
|
||||
"skip_nav": "Skippa till innehåll"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Spellistan har uppdaterats |||| %{smart_count} objekt som har uppdaterats",
|
||||
"created": "Spellistan har skapats",
|
||||
"deleted": "Spellistan har tagits bort |||| %{smart_count} objekt som tagits bort",
|
||||
"bad_item": "Felaktigt element",
|
||||
"item_doesnt_exist": "Spellistan hittades ej",
|
||||
"http_error": "Kommunikationsfel med servern",
|
||||
"data_provider_error": "dataProvider fel. Kontrollera din konsol för ytterligare detaljer.",
|
||||
"i18n_error": "Det gick inte att läsa in översättningen av det begärda språket",
|
||||
"canceled": "Åtgärden avbröts",
|
||||
"logged_out": "Sessionen har löpt ut, anslut igen",
|
||||
"new_version": ""
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "",
|
||||
"layout": "",
|
||||
"grid": "",
|
||||
"table": ""
|
||||
}
|
||||
"album": {
|
||||
"name": "Album |||| Album",
|
||||
"fields": {
|
||||
"albumArtist": "Albumartist",
|
||||
"artist": "Artist",
|
||||
"duration": "Längd",
|
||||
"songCount": "Antal låtar",
|
||||
"playCount": "Spelningar",
|
||||
"size": "Storlek",
|
||||
"name": "Namn",
|
||||
"genre": "Genre",
|
||||
"compilation": "Samling",
|
||||
"year": "År",
|
||||
"originalDate": "Originaldatum",
|
||||
"releaseDate": "Utgivningsdatum",
|
||||
"releases": "Utgåva |||| Utgåvor",
|
||||
"released": "Utgiven",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Betyg",
|
||||
"createdAt": "Skapad"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Spela",
|
||||
"playNext": "Spela härnäst",
|
||||
"addToQueue": "Lägg till i kön",
|
||||
"share": "Dela",
|
||||
"shuffle": "Shuffle",
|
||||
"addToPlaylist": "Lägg till i spellista",
|
||||
"download": "Ladda ner",
|
||||
"info": "Mer information"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alla",
|
||||
"random": "Blanda",
|
||||
"recentlyAdded": "Senast tillagda",
|
||||
"recentlyPlayed": "Senast spelade",
|
||||
"mostPlayed": "Mest spelade",
|
||||
"starred": "Favoriter",
|
||||
"topRated": "Bästa betyg"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "NOTERA",
|
||||
"transcodingDisabled": "Inställning för kodning via webbplattformen är ej aktiverat pga säkerhetsskäl. Starta om servern med alternativet %{config} markerat.",
|
||||
"transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.",
|
||||
"songsAddedToPlaylist": "Lade till 1 låt i spellistan |||| Lade till %{smart_count} låtar i spellistan",
|
||||
"noPlaylistsAvailable": "Ingen tillgänglig",
|
||||
"delete_user_title": "Ta bort användare '%{name}'",
|
||||
"delete_user_content": "Vill du ta bort den här användaren och tillhörande data (inklusive spellistor och inställningar)?",
|
||||
"notifications_blocked": "",
|
||||
"notifications_not_available": "",
|
||||
"lastfmLinkSuccess": "",
|
||||
"lastfmLinkFailure": "",
|
||||
"lastfmUnlinkSuccess": "",
|
||||
"lastfmUnlinkFailure": "",
|
||||
"openIn": {
|
||||
"lastfm": "",
|
||||
"musicbrainz": ""
|
||||
},
|
||||
"lastfmLink": "",
|
||||
"listenBrainzLinkSuccess": "",
|
||||
"listenBrainzLinkFailure": "",
|
||||
"listenBrainzUnlinkSuccess": "",
|
||||
"listenBrainzUnlinkFailure": "",
|
||||
"downloadOriginalFormat": "",
|
||||
"shareOriginalFormat": "",
|
||||
"shareDialogTitle": "",
|
||||
"shareBatchDialogTitle": "",
|
||||
"shareSuccess": "",
|
||||
"shareFailure": "",
|
||||
"downloadDialogTitle": "",
|
||||
"shareCopyToClipboard": ""
|
||||
"artist": {
|
||||
"name": "Artist |||| Artister",
|
||||
"fields": {
|
||||
"name": "Namn",
|
||||
"albumCount": "Antal album",
|
||||
"songCount": "Antal låtar",
|
||||
"size": "Storlek",
|
||||
"playCount": "Spelningar",
|
||||
"rating": "Betyg",
|
||||
"genre": "Genre"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
"settings": "Inställningar",
|
||||
"version": "Version",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Profil",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Språk",
|
||||
"defaultView": "Standardvy",
|
||||
"desktop_notifications": "Skrivbords notiser",
|
||||
"lastfmScrobbling": "",
|
||||
"listenBrainzScrobbling": "",
|
||||
"replaygain": "",
|
||||
"preAmp": "",
|
||||
"gain": {
|
||||
"none": "",
|
||||
"album": "",
|
||||
"track": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Album",
|
||||
"about": "Om",
|
||||
"playlists": "",
|
||||
"sharedPlaylists": ""
|
||||
"user": {
|
||||
"name": "Användare |||| Användare",
|
||||
"fields": {
|
||||
"userName": "Användarnamn",
|
||||
"isAdmin": "Är admin",
|
||||
"lastLoginAt": "Senaste inloggning",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"name": "Namn",
|
||||
"password": "Lösenord",
|
||||
"createdAt": "Skapad",
|
||||
"changePassword": "Byt lösenord?",
|
||||
"currentPassword": "Nuvarande lösenord",
|
||||
"newPassword": "Nytt lösenord",
|
||||
"token": "Token"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Ändringar av ditt namn syns först vid nästa inloggning."
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Användare skapad",
|
||||
"updated": "Användare uppdaterad",
|
||||
"deleted": "Användare borttagen"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Ange din ListenBrainz användar-token.",
|
||||
"clickHereForToken": "Klicka här för att hämta din token"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Spellista",
|
||||
"openText": "Öppna",
|
||||
"closeText": "Stäng",
|
||||
"notContentText": "Ingen musik",
|
||||
"clickToPlayText": "Tryck för att spela",
|
||||
"clickToPauseText": "Tryck för att pausa",
|
||||
"nextTrackText": "Nästa låt",
|
||||
"previousTrackText": "Föregående låt",
|
||||
"reloadText": "Ladda om",
|
||||
"volumeText": "Volym",
|
||||
"toggleLyricText": "Låttext",
|
||||
"toggleMiniModeText": "Minimera",
|
||||
"destroyText": "Ta bort",
|
||||
"downloadText": "Hämta",
|
||||
"removeAudioListsText": "Töm spellista",
|
||||
"clickToDeleteText": "Tryck för att ta bort %{name}",
|
||||
"emptyLyricText": "Ingen låttext",
|
||||
"playModeText": {
|
||||
"order": "Normal",
|
||||
"orderLoop": "Upprepa",
|
||||
"singleLoop": "Upprepa en gång",
|
||||
"shufflePlay": "Blanda"
|
||||
}
|
||||
"name": "Spelare |||| Spelare",
|
||||
"fields": {
|
||||
"name": "Namn",
|
||||
"transcodingId": "Omkodning",
|
||||
"maxBitRate": "Max. bitrate",
|
||||
"client": "Klient",
|
||||
"userName": "Användarnamn",
|
||||
"lastSeen": "Senast sedd",
|
||||
"reportRealPath": "Visa hela sökvägen",
|
||||
"scrobbleEnabled": "Scrobbla till extern tjänst"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Hemsida",
|
||||
"source": "Källkod",
|
||||
"featureRequests": "Github issues"
|
||||
}
|
||||
"transcoding": {
|
||||
"name": "Omkodning |||| Omkodningar",
|
||||
"fields": {
|
||||
"name": "Namn",
|
||||
"targetFormat": "Målformat",
|
||||
"defaultBitRate": "Standardbitrate",
|
||||
"command": "Kommando"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivitet",
|
||||
"totalScanned": "Genomsökta mappar",
|
||||
"quickScan": "Snabb genomsökning",
|
||||
"fullScan": "Fullständig genomsökning\n",
|
||||
"serverUptime": "Server uptime",
|
||||
"serverDown": "OFFLINE"
|
||||
"playlist": {
|
||||
"name": "Spellista |||| Spellistor",
|
||||
"fields": {
|
||||
"name": "Namn",
|
||||
"duration": "Längd",
|
||||
"ownerName": "Ägare",
|
||||
"public": "Offentlig",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"createdAt": "Skapad",
|
||||
"songCount": "Låtar",
|
||||
"comment": "Kommentar",
|
||||
"sync": "Auto-import",
|
||||
"path": "Importera från"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Välj en spellista:",
|
||||
"addNewPlaylist": "Skapa \"%{name}\"",
|
||||
"export": "Exportera",
|
||||
"makePublic": "Gör offentlig",
|
||||
"makePrivate": "Gör privat"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Lägg till dubletter",
|
||||
"song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Hotkeys",
|
||||
"hotkeys": {
|
||||
"show_help": "Visa denna hjälp",
|
||||
"toggle_menu": "Växla meny",
|
||||
"toggle_play": "Spela / Pausa",
|
||||
"prev_song": "Föregående låt",
|
||||
"next_song": "Nästa låt",
|
||||
"vol_up": "Volym Upp",
|
||||
"vol_down": "Volym Ner",
|
||||
"toggle_love": "Lägg till låt i Favoriter",
|
||||
"current_song": ""
|
||||
}
|
||||
"radio": {
|
||||
"name": "Radio |||| Radior",
|
||||
"fields": {
|
||||
"name": "Namn",
|
||||
"streamUrl": "Stream-URL",
|
||||
"homePageUrl": "Hemside-URL",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"createdAt": "Skapad"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Spela nu"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Dela |||| Delningar",
|
||||
"fields": {
|
||||
"username": "Delad av",
|
||||
"url": "URL",
|
||||
"description": "Beskrivning",
|
||||
"downloadable": "Tillåt nedladdning?",
|
||||
"contents": "Innehåll",
|
||||
"expiresAt": "Giltig till",
|
||||
"lastVisitedAt": "Senast besökt",
|
||||
"visitCount": "Besök",
|
||||
"format": "Format",
|
||||
"maxBitRate": "Max. bitrate",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"createdAt": "Skapad"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Tack för att du installerade Navidrome!",
|
||||
"welcome2": "Skapa först ett admin-konto",
|
||||
"confirmPassword": "Bekräfta lösenord",
|
||||
"buttonCreateAdmin": "Skapa admin-konto",
|
||||
"auth_check_error": "Logga in för att fortsätta",
|
||||
"user_menu": "Profil",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"sign_in": "Logga in",
|
||||
"sign_in_error": "Felaktig inloggning, försök igen",
|
||||
"logout": "Logga ut"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Använd enbart bokstäver och siffror",
|
||||
"passwordDoesNotMatch": "Lösenordet matchar inte",
|
||||
"required": "Krävs",
|
||||
"minLength": "Måste ha minst %{min} tecken",
|
||||
"maxLength": "Får maximalt ha %{max} tecken",
|
||||
"minValue": "Måste vara minst %{min}",
|
||||
"maxValue": "Får maximalt vara %{max}",
|
||||
"number": "Måste vara ett nummer",
|
||||
"email": "Måste vara en giltig e-postadress",
|
||||
"oneOf": "Måste vara en av: %{options}",
|
||||
"regex": "Måste matcha ett specifikt format (regexp): %{pattern}",
|
||||
"unique": "Måste vara unik",
|
||||
"url": "Måste vara en giltig URL"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Lägg till filter",
|
||||
"add": "Lägg till",
|
||||
"back": "Tillbaka",
|
||||
"bulk_actions": "1 objekt vald |||| %{smart_count} objekt valda",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Avbryt",
|
||||
"clear_input_value": "Rensa",
|
||||
"clone": "Klona",
|
||||
"confirm": "Bekräfta",
|
||||
"create": "Skapa",
|
||||
"delete": "Ta bort",
|
||||
"edit": "Redigera",
|
||||
"export": "Exportera",
|
||||
"list": "Lista",
|
||||
"refresh": "Uppdatera",
|
||||
"remove_filter": "Ta bort filter",
|
||||
"remove": "Radera",
|
||||
"save": "Spara",
|
||||
"search": "Sök",
|
||||
"show": "Visa",
|
||||
"sort": "Sortera",
|
||||
"undo": "Ångra",
|
||||
"expand": "Expandera",
|
||||
"close": "Stäng",
|
||||
"open_menu": "Öppna meny",
|
||||
"close_menu": "Stäng meny",
|
||||
"unselect": "Avmarkera",
|
||||
"skip": "Hoppa över",
|
||||
"share": "Dela",
|
||||
"download": "Ladda ner"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ja",
|
||||
"false": "Nej"
|
||||
},
|
||||
"page": {
|
||||
"create": "Skapa %{name}",
|
||||
"dashboard": "Dashboard",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Ett fel uppstod",
|
||||
"list": "%{name}",
|
||||
"loading": "Laddar",
|
||||
"not_found": "Hittade inget",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Ingen %{name} ännu.",
|
||||
"invite": "Vill du lägga till en?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Dra och släpp filer som ska laddas upp eller klicka för att välja dem.",
|
||||
"upload_single": "Dra och släpp en fil som ska laddas upp eller klicka för att välja en fil."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Dra och släpp bilder som ska laddas upp eller klicka för att välja dem.",
|
||||
"upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att välja en bild."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Hittade ingen referensdata.",
|
||||
"many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.",
|
||||
"single_missing": "Associerade referenser verkar inte längre vara tillgängliga."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Dölj password",
|
||||
"toggle_hidden": "Visa password"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Om",
|
||||
"are_you_sure": "Är du säker",
|
||||
"bulk_delete_content": "Vill du verkligen ta bort %{name}? |||| Vill du verkligen ta bort dessa %{smart_count} objekt?",
|
||||
"bulk_delete_title": "Ta bort %{name} |||| Ta bort %{smart_count} %{name}",
|
||||
"delete_content": "Vill du verkligen ta bort detta innehåll?",
|
||||
"delete_title": "Ta bort %{name} #%{id}",
|
||||
"details": "Detaljer",
|
||||
"error": "Ett klientfel uppstod och begäran kunde inte slutföras.",
|
||||
"invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel",
|
||||
"loading": "Sidan läses in, var god vänta",
|
||||
"no": "Nej",
|
||||
"not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.",
|
||||
"yes": "Ja",
|
||||
"unsaved_changes": "Du har osparade ändringar. Ignorera dem?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Inga resultat hittades",
|
||||
"no_more_results": "Sidnumret %{page} finns inte. Gå tillbaka till föregående sida.",
|
||||
"page_out_of_boundaries": "Sidnumret %{page} finns inte",
|
||||
"page_out_from_end": "Det finns inga fler sidor",
|
||||
"page_out_from_begin": "Det finns ingen sida före sida 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
|
||||
"page_rows_per_page": "Antal per sida:",
|
||||
"next": "Nästa",
|
||||
"prev": "Föregående",
|
||||
"skip_nav": "Hoppa till innehåll"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Element uppdaterat |||| %{smart_count} element uppdaterade",
|
||||
"created": "Element skapat",
|
||||
"deleted": "Element borttaget |||| %{smart_count} element borttagna",
|
||||
"bad_item": "Felaktigt element",
|
||||
"item_doesnt_exist": "Element finns inte",
|
||||
"http_error": "Kommunikationsfel med servern",
|
||||
"data_provider_error": "Fel i dataProvider. Kontrollera din konsol för mer information",
|
||||
"i18n_error": "Kunde inte läsa in översättningen av det valda språket",
|
||||
"canceled": "Åtgärden avbröts",
|
||||
"logged_out": "Sessionen har avslutats, anslut på nytt",
|
||||
"new_version": "Det finns en ny version! Uppdatera detta fönster."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Kolumner att visa",
|
||||
"layout": "Layout",
|
||||
"grid": "Rutnät",
|
||||
"table": "Tabell"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "OBSERVERA",
|
||||
"transcodingDisabled": "Inställning för kodning via webbgränssnittet är av säkerhetsskäl ej aktiverat. Starta om servern med alternativet %{config} markerat om du vill göra ändringar.",
|
||||
"transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.",
|
||||
"songsAddedToPlaylist": "La till en låt i spellistan |||| La till %{smart_count} låtar i spellistan",
|
||||
"noPlaylistsAvailable": "Ingen tillgänglig",
|
||||
"delete_user_title": "Ta bort användare '%{name}'",
|
||||
"delete_user_content": "Är du säker på att du vill ta bort denna användare och alla deras spellistor och inställningar?",
|
||||
"notifications_blocked": "Du har blockerat meddelanden från denna sajt in din webbläsares inställningar",
|
||||
"notifications_not_available": "Denna webbläsare stödjer inte skrivbordsmeddelanden eller du använder inte Navidrome via https",
|
||||
"lastfmLinkSuccess": "Last.fm är länkat och scrobbling är aktivt",
|
||||
"lastfmLinkFailure": "Last.fm kunde inte länkas",
|
||||
"lastfmUnlinkSuccess": "Last.fm är inte längre länkat och scrobbling är deaktiverat",
|
||||
"lastfmUnlinkFailure": "Last.fm kunde inte avlänkas",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz är länkat och scrobbling är aktivt som användare: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz kunde inte länkas: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz är inte längre länkat och scrobbling är deaktiverat",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz kunde inte avlänkas",
|
||||
"openIn": {
|
||||
"lastfm": "Öppna i Last.fm",
|
||||
"musicbrainz": "Öppna i MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Läs mer...",
|
||||
"shareOriginalFormat": "Dela i originalformat",
|
||||
"shareDialogTitle": "Dela %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Dela en %{resource} |||| Dela %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter",
|
||||
"shareSuccess": "URL kopierades till urklipp: %{url}",
|
||||
"shareFailure": "Fel vid kopiering av URL %{url} till urklipp",
|
||||
"downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})",
|
||||
"downloadOriginalFormat": "Ladda ner i originalformat"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
"settings": "Inställningar",
|
||||
"version": "Version",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Personligt",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Språk",
|
||||
"defaultView": "Standardvy",
|
||||
"desktop_notifications": "Skrivbordsmeddelanden",
|
||||
"lastfmScrobbling": "Scrobbla till Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobbla till ListenBrainz",
|
||||
"replaygain": "ReplayGain-läge",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"gain": {
|
||||
"none": "Inaktiverad",
|
||||
"album": "Använd gain för album",
|
||||
"track": "Använd gain für låtar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Album",
|
||||
"playlists": "Spellistor",
|
||||
"sharedPlaylists": "Delade spellistor",
|
||||
"about": "Om"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Spela kön",
|
||||
"openText": "Öppna",
|
||||
"closeText": "Stäng",
|
||||
"notContentText": "Ingen musik",
|
||||
"clickToPlayText": "Klicka för att spela",
|
||||
"clickToPauseText": "Klicka för att pausa",
|
||||
"nextTrackText": "Nästa låt",
|
||||
"previousTrackText": "Föregående låt",
|
||||
"reloadText": "Ladda om",
|
||||
"volumeText": "Volym",
|
||||
"toggleLyricText": "Låttext av/på",
|
||||
"toggleMiniModeText": "Minimera",
|
||||
"destroyText": "Radera",
|
||||
"downloadText": "Ladda ner",
|
||||
"removeAudioListsText": "Ta bort audiolistor",
|
||||
"clickToDeleteText": "Klicka för att ta bort %{name}",
|
||||
"emptyLyricText": "Ingen låttext",
|
||||
"playModeText": {
|
||||
"order": "I ordningsföljd",
|
||||
"orderLoop": "Upprepa",
|
||||
"singleLoop": "Upprepa en",
|
||||
"shufflePlay": "Shuffle"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Hemsida",
|
||||
"source": "Källkod",
|
||||
"featureRequests": "Funktionalitetförfrågan"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivitet",
|
||||
"totalScanned": "Genomsökta mappar",
|
||||
"quickScan": "Snabbscan",
|
||||
"fullScan": "Komplett scan",
|
||||
"serverUptime": "Serverdrifttid",
|
||||
"serverDown": "OFFLINE"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome kortkommandon",
|
||||
"hotkeys": {
|
||||
"show_help": "Visa denna hjälp",
|
||||
"toggle_menu": "Växla sidomeny",
|
||||
"toggle_play": "Spela / pausa",
|
||||
"prev_song": "Föregående låt",
|
||||
"next_song": "Nästa låt",
|
||||
"current_song": "Hoppa till nuvarande låt",
|
||||
"vol_up": "Volym upp",
|
||||
"vol_down": "Volym ner",
|
||||
"toggle_love": "Lägg till låt i favoriter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"languageName": "Українська",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Пісня Пісні",
|
||||
"name": "Пісня |||| Пісні",
|
||||
"fields": {
|
||||
"albumArtist": "Виконавець альбому",
|
||||
"duration": "Тривалість",
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Альбом Альбоми",
|
||||
"name": "Альбом |||| Альбоми",
|
||||
"fields": {
|
||||
"albumArtist": "Автор Альбому",
|
||||
"artist": "Виконавець",
|
||||
@@ -55,10 +55,10 @@
|
||||
"rating": "Рейтинг",
|
||||
"createdAt": "Додано",
|
||||
"size": "Розмір",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": ""
|
||||
"originalDate": "Оригінал",
|
||||
"releaseDate": "Дата випуску",
|
||||
"releases": "Випуск |||| Випуски",
|
||||
"released": "Випущений"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Прослухати",
|
||||
@@ -81,7 +81,7 @@
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Виконавець Виконавці",
|
||||
"name": "Виконавець |||| Виконавці",
|
||||
"fields": {
|
||||
"name": "Назва",
|
||||
"albumCount": "Кількість альбомів",
|
||||
@@ -93,7 +93,7 @@
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Користувач Користувачі",
|
||||
"name": "Користувач |||| Користувачі",
|
||||
"fields": {
|
||||
"userName": "Ім’я користувача",
|
||||
"isAdmin": "Є адміністратором",
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Програвач Програвачі",
|
||||
"name": "Програвач |||| Програвачі",
|
||||
"fields": {
|
||||
"name": "Назва",
|
||||
"transcodingId": "ID транскодування",
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Транскодування Транскодування",
|
||||
"name": "Транскодувальник |||| Транскодувальники",
|
||||
"fields": {
|
||||
"name": "Назва",
|
||||
"targetFormat": "Цільовий формат",
|
||||
@@ -169,7 +169,7 @@
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Радіо |||| Радіо",
|
||||
"name": "Радіостанція |||| Радіостанції",
|
||||
"fields": {
|
||||
"name": "Назва",
|
||||
"streamUrl": "Посилання на стрім",
|
||||
@@ -232,7 +232,7 @@
|
||||
"add_filter": "Додати фільтр",
|
||||
"add": "Додати",
|
||||
"back": "Повернутися назад",
|
||||
"bulk_actions": "%{smart_count} обрано",
|
||||
"bulk_actions": "1 обрано |||| %{smart_count} обрано",
|
||||
"cancel": "Відмінити",
|
||||
"clear_input_value": "Очистити",
|
||||
"clone": "Клонувати",
|
||||
@@ -381,7 +381,7 @@
|
||||
"version": "Версія",
|
||||
"theme": "Тема",
|
||||
"personal": {
|
||||
"name": "Особистий",
|
||||
"name": "Особисті налаштування",
|
||||
"options": {
|
||||
"theme": "Тема",
|
||||
"language": "Мова",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user