mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-31 19:08:06 -05:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0f1ddecbe | ||
|
|
1e4e3eac6e | ||
|
|
19d443ec7f | ||
|
|
db92cf9e47 | ||
|
|
ec9f9aa243 | ||
|
|
0d1f2bcc8a | ||
|
|
dfa217ab51 | ||
|
|
3d6a2380bc | ||
|
|
53aa640f35 | ||
|
|
e4d65a7828 | ||
|
|
b41123f75e | ||
|
|
6f52c0201c | ||
|
|
4944f8035a | ||
|
|
0d5097d888 | ||
|
|
ed7ee3d9f8 | ||
|
|
74803bb43e | ||
|
|
0159cf73e2 | ||
|
|
ac1d51f9d0 | ||
|
|
91eb661db5 | ||
|
|
524d508916 | ||
|
|
a6f1f7b7e3 | ||
|
|
49b8cfc261 | ||
|
|
bcea8b832a | ||
|
|
58367afaea | ||
|
|
6b59f5f73a | ||
|
|
5f0c1e7387 | ||
|
|
a057a680f1 | ||
|
|
f9081bbe6b | ||
|
|
73eb0e254b | ||
|
|
2b84c574ba | ||
|
|
88f87e6c4f | ||
|
|
cf100c4eb4 | ||
|
|
5ab345c83e | ||
|
|
46a2ec0ba1 | ||
|
|
3394580413 | ||
|
|
112ea281d9 | ||
|
|
c837838d58 | ||
|
|
9e9465567d | ||
|
|
651ce163c7 | ||
|
|
55ce28b2c6 | ||
|
|
d331ee904b | ||
|
|
3a0ce6aafa | ||
|
|
1806552ef6 | ||
|
|
223e88d481 | ||
|
|
57e0f6d3ea | ||
|
|
1c691ac0e6 | ||
|
|
264d73d73e | ||
|
|
296259d781 | ||
|
|
3f9d173495 | ||
|
|
b386981b7f | ||
|
|
be7cb59dc5 | ||
|
|
63dc0e2062 | ||
|
|
1e1dce92b6 | ||
|
|
d78c6f6a04 | ||
|
|
59ece40393 | ||
|
|
491210ac12 | ||
|
|
cd552a55ef | ||
|
|
ee2c2b19e9 | ||
|
|
0147bb5f12 | ||
|
|
1ed8930107 | ||
|
|
e457f21306 | ||
|
|
b04647309f | ||
|
|
2adb098f32 | ||
|
|
212887214c | ||
|
|
beb768cd9c | ||
|
|
ed1109ddb2 | ||
|
|
98808e4b6d | ||
|
|
422ba2284e | ||
|
|
938c3d44cc | ||
|
|
2838ac36df | ||
|
|
b952672877 | ||
|
|
5c0b6fb9b7 | ||
|
|
5fb1db6031 | ||
|
|
226be78bf5 | ||
|
|
7c13878075 | ||
|
|
0bb4b881e9 | ||
|
|
70f536e04d | ||
|
|
2a15a217de | ||
|
|
a28462a7ab | ||
|
|
5c67297dce |
@@ -4,7 +4,7 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.23",
|
||||
"VARIANT": "1.24",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v20"
|
||||
|
||||
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
version: "2"
|
||||
run:
|
||||
build-tags:
|
||||
- netgo
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
@@ -11,42 +11,48 @@ linters:
|
||||
- copyloopvar
|
||||
- dogsled
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errorlint
|
||||
- gocyclo
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nakedret
|
||||
- nilerr
|
||||
- rowserrcheck
|
||||
- staticcheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- whitespace
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: scanner2
|
||||
linters:
|
||||
- unused
|
||||
|
||||
linters-settings:
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
- deprecatedComment
|
||||
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
|
||||
disable:
|
||||
- staticcheck
|
||||
settings:
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
- deprecatedComment
|
||||
gosec:
|
||||
excludes:
|
||||
- G501
|
||||
- G401
|
||||
- G505
|
||||
- G115
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
110
AGENTS.md
Normal file
110
AGENTS.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Testing Instructions
|
||||
|
||||
- **No implementation task is considered complete until it includes thorough, passing tests that cover the new or
|
||||
changed functionality. All new code must be accompanied by Ginkgo/Gomega tests, and PRs/commits without tests should
|
||||
be considered incomplete.**
|
||||
- All Go tests in this project **MUST** be written using the **Ginkgo v2** and **Gomega** frameworks.
|
||||
- To run all tests, use `make test`.
|
||||
- To run tests for a specific package, use `make test PKG=./pkgname/...`
|
||||
- Do not run tests in parallel
|
||||
- Don't use `--fail-on-pending`
|
||||
|
||||
## Mocking Convention
|
||||
|
||||
- Always try to use the mocks provided in the `tests` package before creating a new mock implementation.
|
||||
- Only create a new mock if the required functionality is not covered by the existing mocks in `tests`.
|
||||
- Never mock a real implementation when testing. Remember: there is no value in testing an interface, only the real implementation.
|
||||
|
||||
## Example
|
||||
|
||||
Every package that you write tests for, should have a `*_suite_test.go` file, to hook up the Ginkgo test suite. Example:
|
||||
```
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestCore(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Core Suite")
|
||||
}
|
||||
```
|
||||
Never put a `func Test*` in regular *_test.go files, only in `*_suite_test.go` files.
|
||||
|
||||
Refer to existing test suites for examples of proper setup and usage, such as the one defined in @core_suite_test.go
|
||||
|
||||
## Exceptions
|
||||
|
||||
There should be no exceptions to this rule. If you encounter tests written with the standard `testing` package or other frameworks, they should be refactored to use Ginkgo/Gomega. If you need a new mock, first confirm that it does not already exist in the `tests` package.
|
||||
|
||||
### Configuration
|
||||
|
||||
You can set config values in the BeforeEach/BeforeAll blocks. If you do so, remember to add `DeferCleanup(configtest.SetupConfig())` to reset the values. Example:
|
||||
|
||||
```go
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableDownloads = true
|
||||
})
|
||||
```
|
||||
|
||||
# Logging System Usage Guide
|
||||
|
||||
This project uses a custom logging system built on top of logrus, `log/log.go`. Follow these conventions for all logging:
|
||||
|
||||
## Logging API
|
||||
- Use the provided functions for logging at different levels:
|
||||
- `Error(...)`, `Warn(...)`, `Info(...)`, `Debug(...)`, `Trace(...)`, `Fatal(...)`
|
||||
- These functions accept flexible arguments:
|
||||
- The first argument can be a context (`context.Context`), an HTTP request, or `nil`.
|
||||
- The next argument is the log message (string or error).
|
||||
- Additional arguments are key-value pairs (e.g., `"key", value`).
|
||||
- If the last argument is an error, it is logged under the `error` key.
|
||||
|
||||
**Examples:**
|
||||
```go
|
||||
log.Error("A message")
|
||||
log.Error(ctx, "A message with context")
|
||||
log.Error("Failed to save", "id", 123, err)
|
||||
log.Info(req, "Request received", "user", userID)
|
||||
```
|
||||
|
||||
## Logging errors
|
||||
- You don't need to add "err" key when logging an error, it is automatically added.
|
||||
- Error must always be the last parameter in the log call.
|
||||
Examples:
|
||||
```go
|
||||
log.Error("Failed to save", "id", 123, err) // GOOD
|
||||
log.Error("Failed to save", "id", 123, "err", err) // BAD
|
||||
log.Error("Failed to save", err, "id", 123) // BAD
|
||||
```
|
||||
|
||||
## Context and Request Logging
|
||||
- If a context or HTTP request is passed as the first argument, any logger fields in the context are included in the log entry.
|
||||
- Use `log.NewContext(ctx, "key", value, ...)` to add fields to a context for logging.
|
||||
|
||||
## Log Levels
|
||||
- Set the global log level with `log.SetLevel(log.LevelInfo)` or `log.SetLevelString("info")`.
|
||||
- Per-path log levels can be set with `log.SetLogLevels(map[string]string{"path": "level"})`.
|
||||
- Use `log.IsGreaterOrEqualTo(level)` to check if a log level is enabled for the current code path.
|
||||
|
||||
## Source Line Logging
|
||||
- Enable source file/line logging with `log.SetLogSourceLine(true)`.
|
||||
|
||||
## Best Practices
|
||||
- Always use the logging API, never log directly with logrus or fmt.
|
||||
- Prefer structured logging (key-value pairs) for important data.
|
||||
- Use context/request logging for traceability in web handlers.
|
||||
- For tests, use Ginkgo/Gomega and set up a test logger as in `log/log_test.go`.
|
||||
|
||||
## See Also
|
||||
- `log/log.go` for implementation details
|
||||
- `log/log_test.go` for usage examples and test patterns
|
||||
@@ -61,7 +61,7 @@ COPY --from=ui /build /build
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.23-bookworm AS base
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.24-bookworm AS base
|
||||
RUN apt-get update && apt-get install -y clang lld
|
||||
COPY --from=xx / /
|
||||
WORKDIR /workspace
|
||||
@@ -133,12 +133,12 @@ COPY --from=build /out/navidrome /app/
|
||||
VOLUME ["/data", "/music"]
|
||||
ENV ND_MUSICFOLDER=/music
|
||||
ENV ND_DATAFOLDER=/data
|
||||
ENV ND_CONFIGFILE=/data/navidrome.toml
|
||||
ENV ND_PORT=4533
|
||||
ENV GODEBUG="asyncpreemptoff=1"
|
||||
RUN touch /.nddockerenv
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/app/navidrome"]
|
||||
|
||||
23
Makefile
23
Makefile
@@ -29,15 +29,16 @@ dev: check_env ##@Development Start Navidrome in development mode, with hot-re
|
||||
.PHONY: dev
|
||||
|
||||
server: check_go_env buildjs ##@Development Start the backend in development mode
|
||||
@ND_ENABLEINSIGHTSCOLLECTOR="false" go run github.com/cespare/reflex@latest -d none -c reflex.conf
|
||||
@ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -tags=netgo -notify ./...
|
||||
go tool ginkgo watch -tags=netgo -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
PKG ?= ./...
|
||||
test: ##@Development Run Go tests
|
||||
go test -tags netgo ./...
|
||||
go test -tags netgo $(PKG)
|
||||
.PHONY: test
|
||||
|
||||
testrace: ##@Development Run Go tests with race detector
|
||||
@@ -49,7 +50,7 @@ testall: testrace ##@Development Run Go and JS tests
|
||||
.PHONY: testall
|
||||
|
||||
lint: ##@Development Lint Go code
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
@@ -59,16 +60,16 @@ lintall: lint ##@Development Lint Go and JS code
|
||||
|
||||
format: ##@Development Format code
|
||||
@(cd ./ui && npm run prettier)
|
||||
@go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v _gen.go$$`
|
||||
@go tool goimports -w `find . -name '*.go' | grep -v _gen.go$$`
|
||||
@go mod tidy
|
||||
.PHONY: format
|
||||
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
go run github.com/google/wire/cmd/wire@latest gen -tags=netgo ./...
|
||||
go tool wire gen -tags=netgo ./...
|
||||
.PHONY: wire
|
||||
|
||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo@latest ./server/subsonic/responses/...
|
||||
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
||||
.PHONY: snapshots
|
||||
|
||||
migration-sql: ##@Development Create an empty SQL migration file
|
||||
@@ -156,10 +157,10 @@ package: docker-build ##@Cross_Compilation Create binaries and packages for ALL
|
||||
get-music: ##@Development Download some free music from Navidrome's demo instance
|
||||
mkdir -p music
|
||||
( cd music; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=2Y3qQA6zJC3ObbBrF9ZBoV" > brock.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=04HrSORpypcLGNUdQp37gn" > back_on_earth.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=5xcMPJdeEgNrGtnzYbzAqb" > ugress.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=1jjQMAZrG3lUsJ0YH6ZRS0" > voodoocuts.zip; \
|
||||
for file in *.zip; do unzip -n $${file}; done )
|
||||
@echo "Done. Remember to set your MusicFolder to ./music"
|
||||
.PHONY: get-music
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
JS: sh -c "cd ./ui && npm start"
|
||||
GO: go run github.com/cespare/reflex@latest -d none -c reflex.conf
|
||||
GO: go tool reflex -d none -c reflex.conf
|
||||
|
||||
@@ -82,15 +82,15 @@ var _ = Describe("Extractor", func() {
|
||||
|
||||
// TabLib 1.12 returns 18, previous versions return 39.
|
||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 39, 40, 43, 49))
|
||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.HasPicture).To(BeFalse())
|
||||
Expect(m.HasPicture).To(BeTrue())
|
||||
})
|
||||
|
||||
DescribeTable("Format-Specific tests",
|
||||
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool) {
|
||||
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
|
||||
file = "tests/fixtures/" + file
|
||||
mds, err := e.Parse(file)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
@@ -98,7 +98,7 @@ var _ = Describe("Extractor", func() {
|
||||
|
||||
m := mds[file]
|
||||
|
||||
Expect(m.HasPicture).To(BeFalse())
|
||||
Expect(m.HasPicture).To(Equal(image))
|
||||
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
|
||||
Expect(m.AudioProperties.Channels).To(Equal(channels))
|
||||
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
|
||||
@@ -168,24 +168,24 @@ var _ = Describe("Extractor", func() {
|
||||
},
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
|
||||
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false),
|
||||
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
|
||||
|
||||
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false),
|
||||
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false),
|
||||
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false),
|
||||
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
||||
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
|
||||
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false),
|
||||
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
|
||||
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false),
|
||||
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, false),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
|
||||
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true),
|
||||
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
|
||||
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true),
|
||||
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
|
||||
)
|
||||
|
||||
// Skip these tests when running as root
|
||||
|
||||
@@ -201,40 +201,48 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
char has_cover(const TagLib::FileRef f) {
|
||||
char hasCover = 0;
|
||||
// ----- MP3
|
||||
if (TagLib::MPEG::File *
|
||||
mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) {
|
||||
if (TagLib::MPEG::File * mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) {
|
||||
if (mp3File->ID3v2Tag()) {
|
||||
const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- FLAC
|
||||
else if (TagLib::FLAC::File *
|
||||
flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
|
||||
else if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
|
||||
hasCover = !flacFile->pictureList().isEmpty();
|
||||
}
|
||||
// ----- MP4
|
||||
else if (TagLib::MP4::File *
|
||||
mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) {
|
||||
else if (TagLib::MP4::File * mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) {
|
||||
auto &coverItem{mp4File->tag()->itemMap()["covr"]};
|
||||
TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()};
|
||||
hasCover = !coverArtList.isEmpty();
|
||||
}
|
||||
// ----- Ogg
|
||||
else if (TagLib::Ogg::Vorbis::File *
|
||||
vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
|
||||
else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
|
||||
hasCover = !vorbisFile->tag()->pictureList().isEmpty();
|
||||
}
|
||||
// ----- Opus
|
||||
else if (TagLib::Ogg::Opus::File *
|
||||
opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
|
||||
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
|
||||
hasCover = !opusFile->tag()->pictureList().isEmpty();
|
||||
}
|
||||
// ----- WAV
|
||||
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
|
||||
if (wavFile->hasID3v2Tag()) {
|
||||
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- AIFF
|
||||
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
|
||||
if (aiffFile->hasID3v2Tag()) {
|
||||
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- WMA
|
||||
if (TagLib::ASF::File *
|
||||
asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
|
||||
const TagLib::ASF::Tag *tag{asfFile->tag()};
|
||||
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
|
||||
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
|
||||
const TagLib::ASF::Tag *tag{ asfFile->tag() };
|
||||
hasCover = tag && asfFile->tag()->attributeListMap().contains("WM/Picture");
|
||||
}
|
||||
|
||||
return hasCover;
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
@@ -66,8 +67,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
@@ -80,7 +81,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scannerScanner, broker, playlists, playTracker, share, playbackServer)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -90,8 +91,8 @@ func CreatePublicRouter() *public.Router {
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
@@ -134,8 +135,8 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
@@ -150,8 +151,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/go-viper/encoding/ini"
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -92,6 +93,7 @@ type configOptions struct {
|
||||
PID pidOptions
|
||||
Inspect inspectOptions
|
||||
Subsonic subsonicOptions
|
||||
LyricsPriority string
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
@@ -108,7 +110,6 @@ type configOptions struct {
|
||||
DevActivityPanel bool
|
||||
DevActivityPanelUpdateRate time.Duration
|
||||
DevSidebarPlaylists bool
|
||||
DevEnableBufferedScrobble bool
|
||||
DevShowArtistPage bool
|
||||
DevOffsetOptimize int
|
||||
DevArtworkMaxRequests int
|
||||
@@ -128,7 +129,10 @@ type scannerOptions struct {
|
||||
WatcherWait time.Duration
|
||||
ScanOnStartup bool
|
||||
Extractor string
|
||||
GroupAlbumReleases bool // Deprecated: BFR Update docs
|
||||
ArtistJoiner string
|
||||
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
||||
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
||||
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
||||
}
|
||||
|
||||
type subsonicOptions struct {
|
||||
@@ -139,6 +143,7 @@ type subsonicOptions struct {
|
||||
}
|
||||
|
||||
type TagConf struct {
|
||||
Ignore bool `yaml:"ignore"`
|
||||
Aliases []string `yaml:"aliases"`
|
||||
Type string `yaml:"type"`
|
||||
MaxLength int `yaml:"maxLength"`
|
||||
@@ -302,12 +307,13 @@ func Load(noConfigDump bool) {
|
||||
disableExternalServices()
|
||||
}
|
||||
|
||||
// BFR Remove before release
|
||||
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
||||
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
|
||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
||||
}
|
||||
logDeprecatedOptions("Scanner.GenreSeparators")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
@@ -489,9 +495,12 @@ func init() {
|
||||
viper.SetDefault("scanner.enabled", true)
|
||||
viper.SetDefault("scanner.schedule", "0")
|
||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
|
||||
viper.SetDefault("scanner.scanonstartup", true)
|
||||
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
|
||||
viper.SetDefault("scanner.genreseparators", "")
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
viper.SetDefault("scanner.followsymlinks", true)
|
||||
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
@@ -522,6 +531,8 @@ func init() {
|
||||
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devenableprofiler", false)
|
||||
@@ -529,7 +540,6 @@ func init() {
|
||||
viper.SetDefault("devautologinusername", "")
|
||||
viper.SetDefault("devactivitypanel", true)
|
||||
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
|
||||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
@@ -545,6 +555,10 @@ func init() {
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
codecRegistry := viper.NewCodecRegistry()
|
||||
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})
|
||||
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
|
||||
|
||||
cfgFile = getConfigFile(cfgFile)
|
||||
if cfgFile != "" {
|
||||
// Use config file from the flag.
|
||||
@@ -568,9 +582,17 @@ func InitConfig(cfgFile string) {
|
||||
}
|
||||
}
|
||||
|
||||
// getConfigFile returns the path to the config file, either from the flag or from the environment variable.
|
||||
// If it is defined in the environment variable, it will check if the file exists.
|
||||
func getConfigFile(cfgFile string) string {
|
||||
if cfgFile != "" {
|
||||
return cfgFile
|
||||
}
|
||||
return os.Getenv("ND_CONFIGFILE")
|
||||
cfgFile = os.Getenv("ND_CONFIGFILE")
|
||||
if cfgFile != "" {
|
||||
if _, err := os.Stat(cfgFile); err == nil {
|
||||
return cfgFile
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
50
conf/configuration_test.go
Normal file
50
conf/configuration_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package conf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
. "github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func TestConfiguration(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Configuration Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("Configuration", func() {
|
||||
BeforeEach(func() {
|
||||
// Reset viper configuration
|
||||
viper.Reset()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
ResetConf()
|
||||
})
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
func(format string) {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
// Initialize config with the test file
|
||||
InitConfig(filename)
|
||||
// Load the configuration (with noConfigDump=true)
|
||||
Load(true)
|
||||
|
||||
// Execute the format-specific assertions
|
||||
Expect(Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
|
||||
Expect(Server.UIWelcomeMessage).To(Equal("Welcome " + format))
|
||||
Expect(Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
||||
|
||||
// The config file used should be the one we created
|
||||
Expect(Server.ConfigFile).To(Equal(filename))
|
||||
},
|
||||
Entry("TOML format", "toml"),
|
||||
Entry("YAML format", "yaml"),
|
||||
Entry("INI format", "ini"),
|
||||
Entry("JSON format", "json"),
|
||||
)
|
||||
})
|
||||
5
conf/export_test.go
Normal file
5
conf/export_test.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package conf
|
||||
|
||||
func ResetConf() {
|
||||
Server = &configOptions{}
|
||||
}
|
||||
6
conf/testdata/cfg.ini
vendored
Normal file
6
conf/testdata/cfg.ini
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[default]
|
||||
MusicFolder = /ini/music
|
||||
UIWelcomeMessage = Welcome ini
|
||||
|
||||
[Tags]
|
||||
Custom.Aliases = ini,test
|
||||
12
conf/testdata/cfg.json
vendored
Normal file
12
conf/testdata/cfg.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"musicFolder": "/json/music",
|
||||
"uiWelcomeMessage": "Welcome json",
|
||||
"Tags": {
|
||||
"custom": {
|
||||
"aliases": [
|
||||
"json",
|
||||
"test"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
5
conf/testdata/cfg.toml
vendored
Normal file
5
conf/testdata/cfg.toml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
musicFolder = "/toml/music"
|
||||
uiWelcomeMessage = "Welcome toml"
|
||||
|
||||
[Tags.custom]
|
||||
aliases = ["toml", "test"]
|
||||
7
conf/testdata/cfg.yaml
vendored
Normal file
7
conf/testdata/cfg.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
musicFolder: "/yaml/music"
|
||||
uiWelcomeMessage: "Welcome yaml"
|
||||
Tags:
|
||||
custom:
|
||||
aliases:
|
||||
- yaml
|
||||
- test
|
||||
@@ -151,13 +151,17 @@ var (
|
||||
UnknownArtistID = id.NewHash(strings.ToLower(UnknownArtist))
|
||||
VariousArtistsMbzId = "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
|
||||
ServerStart = time.Now()
|
||||
ArtistJoiner = " • "
|
||||
)
|
||||
|
||||
var InContainer = func() bool {
|
||||
// Check if the /.nddockerenv file exists
|
||||
if _, err := os.Stat("/.nddockerenv"); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}()
|
||||
var (
|
||||
ServerStart = time.Now()
|
||||
|
||||
InContainer = func() bool {
|
||||
// Check if the /.nddockerenv file exists
|
||||
if _, err := os.Stat("/.nddockerenv"); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}()
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ func createAgents(ds model.DataStore) *Agents {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, name)
|
||||
res = append(res, init(ds))
|
||||
res = append(res, agent)
|
||||
}
|
||||
log.Debug("List of agents enabled", "names", enabled)
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
|
||||
return scrobbler.ErrUnrecoverable
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
|
||||
func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
||||
}
|
||||
|
||||
if s.Duration <= 30 {
|
||||
@@ -328,12 +328,12 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
||||
isLastFMError := errors.As(err, &lfErr)
|
||||
if !isLastFMError {
|
||||
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
|
||||
return scrobbler.ErrRetryLater
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
if lfErr.Code == 11 || lfErr.Code == 16 {
|
||||
return scrobbler.ErrRetryLater
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
return scrobbler.ErrUnrecoverable
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
@@ -344,18 +344,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,14 +76,14 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
|
||||
func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
||||
}
|
||||
|
||||
li := l.formatListen(track)
|
||||
err = l.client.updateNowPlaying(ctx, sk, li)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "ListenBrainz updateNowPlaying returned error", "track", track.Title, err)
|
||||
return scrobbler.ErrUnrecoverable
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track
|
||||
func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
||||
}
|
||||
|
||||
li := l.formatListen(&s.MediaFile)
|
||||
@@ -105,12 +105,12 @@ func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrob
|
||||
isListenBrainzError := errors.As(err, &lbErr)
|
||||
if !isListenBrainzError {
|
||||
log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err)
|
||||
return scrobbler.ErrRetryLater
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
if lbErr.Code == 500 || lbErr.Code == 503 {
|
||||
return scrobbler.ErrRetryLater
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
return scrobbler.ErrUnrecoverable
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -24,15 +24,15 @@ type Artwork interface {
|
||||
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 {
|
||||
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, em: em}
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
|
||||
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
|
||||
}
|
||||
|
||||
type artwork struct {
|
||||
ds model.DataStore
|
||||
cache cache.FileCache
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
em core.ExternalMetadata
|
||||
ds model.DataStore
|
||||
cache cache.FileCache
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
provider external.Provider
|
||||
}
|
||||
|
||||
type artworkReader interface {
|
||||
@@ -115,9 +115,9 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
||||
} else {
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
artReader, err = newArtistReader(ctx, a, artID, a.em)
|
||||
artReader, err = newArtistReader(ctx, a, artID, a.provider)
|
||||
case model.KindAlbumArtwork:
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.em)
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindMediaFileArtwork:
|
||||
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
||||
case model.KindPlaylistArtwork:
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// BFR Fix tests
|
||||
// TODO Fix tests
|
||||
var _ = XDescribe("Artwork", func() {
|
||||
var aw *artwork
|
||||
var ds model.DataStore
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
@@ -19,14 +21,14 @@ import (
|
||||
type albumArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
em core.ExternalMetadata
|
||||
provider external.Provider
|
||||
album model.Album
|
||||
updatedAt *time.Time
|
||||
imgFiles []string
|
||||
rootFolder string
|
||||
}
|
||||
|
||||
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*albumArtworkReader, error) {
|
||||
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) {
|
||||
al, err := artwork.ds.Album(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -37,7 +39,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
||||
}
|
||||
a := &albumArtworkReader{
|
||||
a: artwork,
|
||||
em: em,
|
||||
provider: provider,
|
||||
album: *al,
|
||||
updatedAt: imagesUpdateAt,
|
||||
imgFiles: imgFiles,
|
||||
@@ -82,7 +84,7 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
|
||||
embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
|
||||
ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.em))
|
||||
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
|
||||
case len(a.imgFiles) > 0:
|
||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern))
|
||||
}
|
||||
@@ -112,5 +114,10 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
imgFiles = append(imgFiles, filepath.Join(path, img))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort image files to ensure consistent selection of cover art
|
||||
// This prioritizes files from lower-numbered disc folders by sorting the paths
|
||||
slices.Sort(imgFiles)
|
||||
|
||||
return paths, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
76
core/artwork/reader_album_test.go
Normal file
76
core/artwork/reader_album_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Album Artwork Reader", func() {
|
||||
Describe("loadAlbumFoldersPaths", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *fakeDataStore
|
||||
repo *fakeFolderRepo
|
||||
album model.Album
|
||||
now time.Time
|
||||
expectedAt time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
now = time.Now().Truncate(time.Second)
|
||||
expectedAt = now.Add(5 * time.Minute)
|
||||
|
||||
// Set up the test folders with image files
|
||||
repo = &fakeFolderRepo{
|
||||
result: []model.Folder{
|
||||
{
|
||||
Path: "Artist/Album/Disc1",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg", "back.jpg"},
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc2",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc10",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
},
|
||||
err: nil,
|
||||
}
|
||||
ds = &fakeDataStore{
|
||||
folderRepo: repo,
|
||||
}
|
||||
album = model.Album{
|
||||
ID: "album1",
|
||||
Name: "Album",
|
||||
FolderIDs: []string{"folder1", "folder2", "folder3"},
|
||||
}
|
||||
})
|
||||
|
||||
It("returns sorted image files", func() {
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
|
||||
// Check that image files are sorted alphabetically
|
||||
Expect(imgFiles).To(HaveLen(4))
|
||||
|
||||
// The files should be sorted by full path
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
|
||||
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
|
||||
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
|
||||
Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
@@ -22,13 +23,13 @@ import (
|
||||
type artistReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
em core.ExternalMetadata
|
||||
provider external.Provider
|
||||
artist model.Artist
|
||||
artistFolder string
|
||||
imgFiles []string
|
||||
}
|
||||
|
||||
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*artistReader, error) {
|
||||
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -53,7 +54,7 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
|
||||
}
|
||||
a := &artistReader{
|
||||
a: artwork,
|
||||
em: em,
|
||||
provider: provider,
|
||||
artist: *ar,
|
||||
artistFolder: artistFolder,
|
||||
imgFiles: imgFiles,
|
||||
@@ -95,7 +96,7 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.em))
|
||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
||||
default:
|
||||
|
||||
@@ -63,12 +63,12 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||
|
||||
resized, origSize, err := resizeImage(orig, a.size, a.square)
|
||||
if resized == nil {
|
||||
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size)
|
||||
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square)
|
||||
} else {
|
||||
log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size)
|
||||
log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, err)
|
||||
log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, "square", a.square, err)
|
||||
}
|
||||
if err != nil || resized == nil {
|
||||
// if we couldn't resize the image, return the original
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -157,9 +157,9 @@ func fromAlbumPlaceholder() sourceFunc {
|
||||
return r, consts.PlaceholderAlbumArt, nil
|
||||
}
|
||||
}
|
||||
func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.ExternalMetadata) sourceFunc {
|
||||
func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := em.ArtistImage(ctx, ar.ID)
|
||||
imageUrl, err := provider.ArtistImage(ctx, ar.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -168,9 +168,9 @@ func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.Exte
|
||||
}
|
||||
}
|
||||
|
||||
func fromAlbumExternalSource(ctx context.Context, al model.Album, em core.ExternalMetadata) sourceFunc {
|
||||
func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := em.AlbumImage(ctx, al.ID)
|
||||
imageUrl, err := provider.AlbumImage(ctx, al.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
55
core/common_test.go
Normal file
55
core/common_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
)
|
||||
|
||||
var _ = Describe("common.go", func() {
|
||||
Describe("userName", func() {
|
||||
It("returns the username from context", func() {
|
||||
ctx := request.WithUser(context.Background(), model.User{UserName: "testuser"})
|
||||
Expect(userName(ctx)).To(Equal("testuser"))
|
||||
})
|
||||
|
||||
It("returns 'UNKNOWN' if no user in context", func() {
|
||||
ctx := context.Background()
|
||||
Expect(userName(ctx)).To(Equal("UNKNOWN"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AbsolutePath", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
libId int
|
||||
path string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
libId = 1
|
||||
path = "music/file.mp3"
|
||||
mockLib := &tests.MockLibraryRepo{}
|
||||
mockLib.SetData(model.Libraries{{ID: libId, Path: "/library/root"}})
|
||||
ds.MockedLibrary = mockLib
|
||||
})
|
||||
|
||||
It("returns the absolute path when library exists", func() {
|
||||
ctx := context.Background()
|
||||
abs := AbsolutePath(ctx, ds, libId, path)
|
||||
Expect(abs).To(Equal("/library/root/music/file.mp3"))
|
||||
})
|
||||
|
||||
It("returns the original path if library not found", func() {
|
||||
ctx := context.Background()
|
||||
abs := AbsolutePath(ctx, ds, 999, path)
|
||||
Expect(abs).To(Equal(path))
|
||||
})
|
||||
})
|
||||
})
|
||||
270
core/external/extdata_helper_test.go
vendored
Normal file
270
core/external/extdata_helper_test.go
vendored
Normal file
@@ -0,0 +1,270 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// --- Shared Mock Implementations ---
|
||||
|
||||
// mockArtistRepo mocks model.ArtistRepository
|
||||
type mockArtistRepo struct {
|
||||
mock.Mock
|
||||
model.ArtistRepository
|
||||
}
|
||||
|
||||
func newMockArtistRepo() *mockArtistRepo {
|
||||
return &mockArtistRepo{}
|
||||
}
|
||||
|
||||
// SetData sets up basic Get expectations.
|
||||
func (m *mockArtistRepo) SetData(artists model.Artists) {
|
||||
for _, a := range artists {
|
||||
artistCopy := a
|
||||
m.On("Get", artistCopy.ID).Return(&artistCopy, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Get implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.Artist), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.Artists), args.Error(1)
|
||||
}
|
||||
|
||||
// SetError is a helper to set up a generic error for GetAll.
|
||||
func (m *mockArtistRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
|
||||
}
|
||||
}
|
||||
|
||||
// FindByName is a helper to set up a GetAll expectation for finding by name.
|
||||
func (m *mockArtistRepo) FindByName(name string, artist model.Artist) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.Artists{artist}, nil).Once()
|
||||
}
|
||||
|
||||
// mockMediaFileRepo mocks model.MediaFileRepository
|
||||
type mockMediaFileRepo struct {
|
||||
mock.Mock
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func newMockMediaFileRepo() *mockMediaFileRepo {
|
||||
return &mockMediaFileRepo{}
|
||||
}
|
||||
|
||||
// SetData sets up basic Get expectations.
|
||||
func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
|
||||
for _, mf := range mediaFiles {
|
||||
mfCopy := mf
|
||||
m.On("Get", mfCopy.ID).Return(&mfCopy, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Get implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.MediaFile), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
// SetError is a helper to set up a generic error for GetAll.
|
||||
func (m *mockMediaFileRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
|
||||
}
|
||||
}
|
||||
|
||||
// FindByMBID is a helper to set up a GetAll expectation for finding by MBID.
|
||||
func (m *mockMediaFileRepo) FindByMBID(mbid string, mediaFile model.MediaFile) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.MediaFiles{mediaFile}, nil).Once()
|
||||
}
|
||||
|
||||
// FindByArtistAndTitle is a helper to set up a GetAll expectation for finding by artist/title.
|
||||
func (m *mockMediaFileRepo) FindByArtistAndTitle(artistID string, title string, mediaFile model.MediaFile) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.MediaFiles{mediaFile}, nil).Once()
|
||||
}
|
||||
|
||||
// mockAlbumRepo mocks model.AlbumRepository
|
||||
type mockAlbumRepo struct {
|
||||
mock.Mock
|
||||
model.AlbumRepository
|
||||
}
|
||||
|
||||
func newMockAlbumRepo() *mockAlbumRepo {
|
||||
return &mockAlbumRepo{}
|
||||
}
|
||||
|
||||
// Get implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.Album), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.Albums), args.Error(1)
|
||||
}
|
||||
|
||||
// mockSimilarArtistAgent mocks agents implementing ArtistTopSongsRetriever and ArtistSimilarRetriever
|
||||
type mockSimilarArtistAgent struct {
|
||||
mock.Mock
|
||||
agents.Interface // Embed to satisfy methods not explicitly mocked
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) AgentName() string {
|
||||
return "mockSimilar"
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
args := m.Called(ctx, id, artistName, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
args := m.Called(ctx, id, name, mbid, limit)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Artist), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
// mockAgents mocks the main Agents interface used by Provider
|
||||
type mockAgents struct {
|
||||
mock.Mock // Embed testify mock
|
||||
topSongsAgent agents.ArtistTopSongsRetriever
|
||||
similarAgent agents.ArtistSimilarRetriever
|
||||
imageAgent agents.ArtistImageRetriever
|
||||
albumInfoAgent agents.AlbumInfoRetriever
|
||||
bioAgent agents.ArtistBiographyRetriever
|
||||
mbidAgent agents.ArtistMBIDRetriever
|
||||
urlAgent agents.ArtistURLRetriever
|
||||
agents.Interface
|
||||
}
|
||||
|
||||
func (m *mockAgents) AgentName() string {
|
||||
return "mockCombined"
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
if m.similarAgent != nil {
|
||||
return m.similarAgent.GetSimilarArtists(ctx, id, name, mbid, limit)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid, limit)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Artist), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
if m.topSongsAgent != nil {
|
||||
return m.topSongsAgent.GetArtistTopSongs(ctx, id, artistName, mbid, count)
|
||||
}
|
||||
args := m.Called(ctx, id, artistName, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
if m.albumInfoAgent != nil {
|
||||
return m.albumInfoAgent.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
}
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
if m.mbidAgent != nil {
|
||||
return m.mbidAgent.GetArtistMBID(ctx, id, name)
|
||||
}
|
||||
args := m.Called(ctx, id, name)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if m.urlAgent != nil {
|
||||
return m.urlAgent.GetArtistURL(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if m.bioAgent != nil {
|
||||
return m.bioAgent.GetArtistBiography(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
if m.imageAgent != nil {
|
||||
return m.imageAgent.GetArtistImages(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.ExternalImage), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
17
core/external/extdata_suite_test.go
vendored
Normal file
17
core/external/extdata_suite_test.go
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestExternal(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "External Suite")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package external
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
refreshQueueLength = 2000
|
||||
)
|
||||
|
||||
type ExternalMetadata interface {
|
||||
type Provider interface {
|
||||
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
|
||||
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
|
||||
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
||||
@@ -40,9 +40,9 @@ type ExternalMetadata interface {
|
||||
AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||
}
|
||||
|
||||
type externalMetadata struct {
|
||||
type provider struct {
|
||||
ds model.DataStore
|
||||
ag *agents.Agents
|
||||
ag Agents
|
||||
artistQueue refreshQueue[auxArtist]
|
||||
albumQueue refreshQueue[auxAlbum]
|
||||
}
|
||||
@@ -57,14 +57,24 @@ type auxArtist struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
|
||||
e := &externalMetadata{ds: ds, ag: agents}
|
||||
type Agents interface {
|
||||
agents.AlbumInfoRetriever
|
||||
agents.ArtistBiographyRetriever
|
||||
agents.ArtistMBIDRetriever
|
||||
agents.ArtistImageRetriever
|
||||
agents.ArtistSimilarRetriever
|
||||
agents.ArtistTopSongsRetriever
|
||||
agents.ArtistURLRetriever
|
||||
}
|
||||
|
||||
func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
e := &provider{ds: ds, ag: agents}
|
||||
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
||||
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
||||
var entity interface{}
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
@@ -81,10 +91,11 @@ func (e *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, e
|
||||
default:
|
||||
return auxAlbum{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
|
||||
func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
|
||||
album, err := e.getAlbum(ctx, id)
|
||||
if err != nil {
|
||||
log.Info(ctx, "Not found", "id", id)
|
||||
@@ -109,7 +120,7 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
|
||||
return &album.Album, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
|
||||
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
|
||||
start := time.Now()
|
||||
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
@@ -155,7 +166,7 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist, error) {
|
||||
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) {
|
||||
var entity interface{}
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
@@ -177,7 +188,7 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist,
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
func (e *provider) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
artist, err := e.refreshArtistInfo(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -187,7 +198,7 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
|
||||
return &artist.Artist, err
|
||||
}
|
||||
|
||||
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
|
||||
func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
@@ -211,7 +222,7 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (au
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
|
||||
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
|
||||
start := time.Now()
|
||||
// Get MBID first, if it is not yet available
|
||||
if artist.MbzArtistID == "" {
|
||||
@@ -246,7 +257,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArt
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -304,7 +315,7 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
||||
return similarSongs, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -318,24 +329,35 @@ func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL
|
||||
|
||||
imageUrl := artist.ArtistImageUrl()
|
||||
if imageUrl == "" {
|
||||
return nil, agents.ErrNotFound
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return url.Parse(imageUrl)
|
||||
}
|
||||
|
||||
func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
album, err := e.getAlbum(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "GetAlbumInfo call canceled", err)
|
||||
default:
|
||||
log.Warn(ctx, "Error getting album info from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "AlbumImage call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
|
||||
if info == nil {
|
||||
log.Warn(ctx, "Agent returned nil info without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
// Return the biggest image
|
||||
@@ -346,26 +368,37 @@ func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL,
|
||||
}
|
||||
}
|
||||
if img.URL == "" {
|
||||
return nil, agents.ErrNotFound
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return url.Parse(img.URL)
|
||||
}
|
||||
|
||||
func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
|
||||
func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.findArtistByName(ctx, artistName)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Artist not found", "name", artistName, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return e.getMatchingTopSongs(ctx, e.ag, artist, count)
|
||||
songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "TopSongs not found", "name", artistName)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "TopSongs call canceled", err)
|
||||
default:
|
||||
log.Warn(ctx, "Error getting top songs from agent", "artist", artistName, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
|
||||
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
|
||||
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -386,10 +419,11 @@ func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents
|
||||
} else {
|
||||
log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
|
||||
}
|
||||
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
|
||||
func (e *provider) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
|
||||
if mbid != "" {
|
||||
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
@@ -420,7 +454,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
||||
return &mfs[0], nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -428,7 +462,7 @@ func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistUR
|
||||
artist.ExternalUrl = artisURL
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -438,7 +472,7 @@ func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.Ar
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -456,7 +490,7 @@ func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.Artist
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
limit int, includeNotPresent bool) {
|
||||
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
|
||||
if len(similar) == 0 || err != nil {
|
||||
@@ -471,7 +505,7 @@ func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.Arti
|
||||
artist.SimilarArtists = sa
|
||||
}
|
||||
|
||||
func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) {
|
||||
func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) {
|
||||
var result model.Artists
|
||||
var notPresent []string
|
||||
|
||||
@@ -515,7 +549,7 @@ func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agen
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
||||
func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"artist.name": artistName},
|
||||
Max: 1,
|
||||
@@ -533,7 +567,7 @@ func (e *externalMetadata) findArtistByName(ctx context.Context, artistName stri
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
|
||||
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
|
||||
var ids []string
|
||||
for _, sa := range artist.SimilarArtists {
|
||||
if sa.ID == "" {
|
||||
303
core/external/provider_albumimage_test.go
vendored
Normal file
303
core/external/provider_albumimage_test.go
vendored
Normal file
@@ -0,0 +1,303 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - AlbumImage", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var provider Provider
|
||||
var mockArtistRepo *mockArtistRepo
|
||||
var mockAlbumRepo *mockAlbumRepo
|
||||
var mockMediaFileRepo *mockMediaFileRepo
|
||||
var mockAlbumAgent *mockAlbumInfoAgent
|
||||
var agentsCombined *mockAgents
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Agents = "mockAlbum" // Configure mock agent
|
||||
|
||||
mockArtistRepo = newMockArtistRepo()
|
||||
mockAlbumRepo = newMockAlbumRepo()
|
||||
mockMediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: mockArtistRepo,
|
||||
MockedAlbum: mockAlbumRepo,
|
||||
MockedMediaFile: mockMediaFileRepo,
|
||||
}
|
||||
|
||||
mockAlbumAgent = newMockAlbumInfoAgent()
|
||||
|
||||
agentsCombined = &mockAgents{
|
||||
albumInfoAgent: mockAlbumAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
|
||||
// Default mocks
|
||||
// Mocks for GetEntityByID sequence (initial failed lookups)
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
// Default mock for non-existent entities - Use Maybe() for flexibility
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
})
|
||||
|
||||
It("returns the largest image URL when successful", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist name
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the album is not found in the DB", func() {
|
||||
// Arrange: Explicitly expect the full GetEntityByID sequence for "not-found"
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "not-found")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns the agent error if the agent fails", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
agentErr := errors.New("agent failure")
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("agent failure"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns no images", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{Images: []agents.ExternalImage{}}, nil).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns context error if context is canceled", func() {
|
||||
// Arrange
|
||||
cctx, cancelCtx := context.WithCancel(ctx)
|
||||
// Mock the necessary DB calls *before* canceling the context
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Expect the agent call even if context is cancelled, returning the context error
|
||||
mockAlbumAgent.On("GetAlbumInfo", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
|
||||
// Cancel the context *before* calling the function under test
|
||||
cancelCtx()
|
||||
|
||||
imgURL, err := provider.AlbumImage(cctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("context canceled"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
// Agent should now be called, verify this expectation
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", cctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("derives album ID from MediaFile ID", func() {
|
||||
// Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1"
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once()
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "mf-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("handles different image orders from agent", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("handles agent returning only one image", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/single.jpg", Size: 700},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/single.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if deriving album ID fails", func() {
|
||||
// Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found"
|
||||
mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once()
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "mf-no-album")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
})
|
||||
|
||||
// mockAlbumInfoAgent implementation
|
||||
type mockAlbumInfoAgent struct {
|
||||
mock.Mock
|
||||
agents.AlbumInfoRetriever // Embed interface
|
||||
}
|
||||
|
||||
func newMockAlbumInfoAgent() *mockAlbumInfoAgent {
|
||||
m := new(mockAlbumInfoAgent)
|
||||
m.On("AgentName").Return("mockAlbum").Maybe()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) AgentName() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
|
||||
}
|
||||
|
||||
// Ensure mockAgent implements the interface
|
||||
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)
|
||||
301
core/external/provider_artistimage_test.go
vendored
Normal file
301
core/external/provider_artistimage_test.go
vendored
Normal file
@@ -0,0 +1,301 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - ArtistImage", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var provider Provider
|
||||
var mockArtistRepo *mockArtistRepo
|
||||
var mockAlbumRepo *mockAlbumRepo
|
||||
var mockMediaFileRepo *mockMediaFileRepo
|
||||
var mockImageAgent *mockArtistImageAgent
|
||||
var agentsCombined *mockAgents
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Agents = "mockImage" // Configure only the mock agent
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
mockArtistRepo = newMockArtistRepo()
|
||||
mockAlbumRepo = newMockAlbumRepo()
|
||||
mockMediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: mockArtistRepo,
|
||||
MockedAlbum: mockAlbumRepo,
|
||||
MockedMediaFile: mockMediaFileRepo,
|
||||
}
|
||||
|
||||
mockImageAgent = newMockArtistImageAgent()
|
||||
|
||||
// Use the mockAgents from helper, setting the specific agent
|
||||
agentsCombined = &mockAgents{
|
||||
imageAgent: mockImageAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
|
||||
// Default mocks for successful Get calls
|
||||
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe()
|
||||
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe()
|
||||
// Default mock for non-existent entities
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
|
||||
// Default successful image agent response
|
||||
mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Maybe()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
mockArtistRepo.AssertExpectations(GinkgoT())
|
||||
mockAlbumRepo.AssertExpectations(GinkgoT())
|
||||
mockMediaFileRepo.AssertExpectations(GinkgoT())
|
||||
mockImageAgent.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns the largest image URL when successful", func() {
|
||||
// Arrange
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the artist is not found in the DB", func() {
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "not-found")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns the agent error if the agent fails", func() {
|
||||
// Arrange
|
||||
agentErr := errors.New("agent failure")
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns no images", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns context error if context is canceled before agent call", func() {
|
||||
// Arrange
|
||||
cctx, cancelCtx := context.WithCancel(context.Background())
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well
|
||||
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) {
|
||||
cancelCtx() // Cancel context *during* the DB call simulation
|
||||
}).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(cctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
})
|
||||
|
||||
It("derives artist ID from MediaFile ID", func() {
|
||||
// Arrange: Add mocks for the initial GetEntityByID lookups
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
// Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "mf-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("derives artist ID from Album ID", func() {
|
||||
// Arrange: Add mock for the initial GetEntityByID lookup
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
// Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "album-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if derived artist is not found", func() {
|
||||
// Arrange
|
||||
// Add mocks for the initial GetEntityByID lookups
|
||||
mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once()
|
||||
// Add expectation for the recursive GetEntityByID call for the MediaFileRepo
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
// The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("handles different image orders from agent", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("handles agent returning only one image", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
expectedURL, _ := url.Parse("http://example.com/medium.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
})
|
||||
|
||||
// mockArtistImageAgent implementation using testify/mock
|
||||
// This remains local as it's specific to testing the ArtistImage functionality
|
||||
type mockArtistImageAgent struct {
|
||||
mock.Mock
|
||||
agents.ArtistImageRetriever // Embed interface
|
||||
}
|
||||
|
||||
// Constructor for the mock agent
|
||||
func newMockArtistImageAgent() *mockArtistImageAgent {
|
||||
mock := new(mockArtistImageAgent)
|
||||
// Set default AgentName if needed, although usually called via mockAgents
|
||||
mock.On("AgentName").Return("mockImage").Maybe()
|
||||
return mock
|
||||
}
|
||||
|
||||
func (m *mockArtistImageAgent) AgentName() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) {
|
||||
args := m.Called(ctx, id, artistName, mbid)
|
||||
// Need careful type assertion for potentially nil slice
|
||||
var res []agents.ExternalImage
|
||||
if args.Get(0) != nil {
|
||||
res = args.Get(0).([]agents.ExternalImage)
|
||||
}
|
||||
return res, args.Error(1)
|
||||
}
|
||||
|
||||
// Ensure mockAgent implements the interface
|
||||
var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil)
|
||||
198
core/external/provider_similarsongs_test.go
vendored
Normal file
198
core/external/provider_similarsongs_test.go
vendored
Normal file
@@ -0,0 +1,198 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - SimilarSongs", func() {
|
||||
var ds model.DataStore
|
||||
var provider Provider
|
||||
var mockAgent *mockSimilarArtistAgent
|
||||
var mockTopAgent agents.ArtistTopSongsRetriever
|
||||
var mockSimilarAgent agents.ArtistSimilarRetriever
|
||||
var agentsCombined Agents
|
||||
var artistRepo *mockArtistRepo
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
|
||||
mockAgent = &mockSimilarArtistAgent{}
|
||||
mockTopAgent = mockAgent
|
||||
mockSimilarAgent = mockAgent
|
||||
|
||||
agentsCombined = &mockAgents{
|
||||
topSongsAgent: mockTopAgent,
|
||||
similarAgent: mockSimilarAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
})
|
||||
|
||||
It("returns similar songs from main artist and similar artists", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
|
||||
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
similarAgentsResp := []agents.Artist{
|
||||
{Name: "Similar Artist", MBID: "similar-mbid"},
|
||||
}
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(similarAgentsResp, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{similarArtist}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
{Name: "Song Two", MBID: "mbid-2"},
|
||||
}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song Three", MBID: "mbid-3"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.FindByMBID("mbid-1", song1)
|
||||
mediaFileRepo.FindByMBID("mbid-2", song2)
|
||||
mediaFileRepo.FindByMBID("mbid-3", song3)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(3))
|
||||
for _, song := range songs {
|
||||
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when artist is not found", func() {
|
||||
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
|
||||
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
Expect(songs).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns songs from main artist when GetSimilarArtists returns error", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(nil, errors.New("error getting similar artists")).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.FindByMBID("mbid-1", song1)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
|
||||
It("returns empty list when GetArtistTopSongs returns error", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return(nil, errors.New("error getting top songs")).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("respects count parameter", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
{Name: "Song Two", MBID: "mbid-2"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.FindByMBID("mbid-1", song1)
|
||||
mediaFileRepo.FindByMBID("mbid-2", song2)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
|
||||
})
|
||||
})
|
||||
193
core/external/provider_topsongs_test.go
vendored
Normal file
193
core/external/provider_topsongs_test.go
vendored
Normal file
@@ -0,0 +1,193 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - TopSongs", func() {
|
||||
var (
|
||||
p Provider
|
||||
artistRepo *mockArtistRepo // From provider_helper_test.go
|
||||
mediaFileRepo *mockMediaFileRepo // From provider_helper_test.go
|
||||
ag *mockAgents // Consolidated mock from export_test.go
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo() // Use helper mock
|
||||
mediaFileRepo = newMockMediaFileRepo() // Use helper mock
|
||||
|
||||
// Configure tests.MockDataStore to use the testify/mock-based repos
|
||||
ds := &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
|
||||
ag = new(mockAgents)
|
||||
|
||||
p = NewProvider(ds, ag)
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
// Setup expectations in individual tests
|
||||
})
|
||||
|
||||
It("returns top songs for a known artist", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-song-1"},
|
||||
{Name: "Song Two", MBID: "mbid-song-2"},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching tracks
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
Expect(songs[1].ID).To(Equal("song-2"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns nil for an unknown artist", func() {
|
||||
// Mock artist not found
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Unknown Artist", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred()) // TopSongs returns nil error if artist not found
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns error when the agent returns an error", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent error
|
||||
agentErr := errors.New("agent error")
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agentErr).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(agentErr))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when the agent returns ErrNotFound", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent ErrNotFound
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns fewer songs if count is less than available top songs", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response (only need 1 for the test)
|
||||
agentSongs := []agents.Song{{Name: "Song One", MBID: "mbid-song-1"}}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching track
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns fewer songs if fewer matching tracks are found", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-song-1"},
|
||||
{Name: "Song Two", MBID: "mbid-song-2"},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching tracks (only find song 1)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For mbid-song-2 (fails)
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For title fallback (fails)
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns error when context is canceled during agent call", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Setup context that will be canceled
|
||||
canceledCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
// Mock agent call to return context canceled error
|
||||
ag.On("GetArtistTopSongs", canceledCtx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, context.Canceled).Once()
|
||||
|
||||
cancel() // Cancel the context before calling
|
||||
songs, err := p.TopSongs(canceledCtx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
170
core/external/provider_updatealbuminfo_test.go
vendored
Normal file
170
core/external/provider_updatealbuminfo_test.go
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.LevelDebug)
|
||||
}
|
||||
|
||||
var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
p external.Provider
|
||||
ds *tests.MockDataStore
|
||||
ag *mockAgents
|
||||
mockAlbumRepo *tests.MockAlbumRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
|
||||
})
|
||||
|
||||
It("returns error when album is not found", func() {
|
||||
album, err := p.UpdateAlbumInfo(ctx, "al-not-found")
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(album).To(BeNil())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("populates info when album exists but has no external info", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-existing",
|
||||
Name: "Test Album",
|
||||
AlbumArtist: "Test Artist",
|
||||
MbzAlbumID: "mbid-album",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
expectedInfo := &agents.AlbumInfo{
|
||||
URL: "http://example.com/album",
|
||||
Description: "Album Description",
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 300},
|
||||
{URL: "http://example.com/medium.jpg", Size: 200},
|
||||
{URL: "http://example.com/small.jpg", Size: 100},
|
||||
},
|
||||
}
|
||||
ag.On("GetAlbumInfo", ctx, "Test Album", "Test Artist", "mbid-album").Return(expectedInfo, nil)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-existing")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(updatedAlbum.ID).To(Equal("al-existing"))
|
||||
Expect(updatedAlbum.ExternalUrl).To(Equal("http://example.com/album"))
|
||||
Expect(updatedAlbum.Description).To(Equal("Album Description"))
|
||||
Expect(updatedAlbum.LargeImageUrl).To(Equal("http://example.com/large.jpg"))
|
||||
Expect(updatedAlbum.MediumImageUrl).To(Equal("http://example.com/medium.jpg"))
|
||||
Expect(updatedAlbum.SmallImageUrl).To(Equal("http://example.com/small.jpg"))
|
||||
Expect(updatedAlbum.ExternalInfoUpdatedAt).NotTo(BeNil())
|
||||
Expect(*updatedAlbum.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns cached info when album exists and info is not expired", func() {
|
||||
now := time.Now()
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-cached",
|
||||
Name: "Cached Album",
|
||||
AlbumArtist: "Cached Artist",
|
||||
ExternalUrl: "http://cached.com/album",
|
||||
Description: "Cached Desc",
|
||||
LargeImageUrl: "http://cached.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-cached")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns cached info and triggers background refresh when info is expired", func() {
|
||||
now := time.Now()
|
||||
expiredTime := now.Add(-conf.Server.DevAlbumInfoTimeToLive * 2)
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-expired",
|
||||
Name: "Expired Album",
|
||||
AlbumArtist: "Expired Artist",
|
||||
ExternalUrl: "http://expired.com/album",
|
||||
Description: "Expired Desc",
|
||||
LargeImageUrl: "http://expired.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-expired")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns error when agent fails to get album info", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-agent-error",
|
||||
Name: "Agent Error Album",
|
||||
AlbumArtist: "Agent Error Artist",
|
||||
MbzAlbumID: "mbid-agent-error",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
expectedErr := errors.New("agent communication failed")
|
||||
ag.On("GetAlbumInfo", ctx, "Agent Error Album", "Agent Error Artist", "mbid-agent-error").Return(nil, expectedErr)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-agent-error")
|
||||
|
||||
Expect(err).To(MatchError(expectedErr))
|
||||
Expect(updatedAlbum).To(BeNil())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns original album when agent returns ErrNotFound", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-agent-notfound",
|
||||
Name: "Agent NotFound Album",
|
||||
AlbumArtist: "Agent NotFound Artist",
|
||||
MbzAlbumID: "mbid-agent-notfound",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
ag.On("GetAlbumInfo", ctx, "Agent NotFound Album", "Agent NotFound Artist", "mbid-agent-notfound").Return(nil, agents.ErrNotFound)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-agent-notfound")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
Expect(updatedAlbum.ExternalInfoUpdatedAt).To(BeNil())
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
229
core/external/provider_updateartistinfo_test.go
vendored
Normal file
229
core/external/provider_updateartistinfo_test.go
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.LevelDebug)
|
||||
}
|
||||
|
||||
var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
p external.Provider
|
||||
ds *tests.MockDataStore
|
||||
ag *mockAgents
|
||||
mockArtistRepo *tests.MockArtistRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DevArtistInfoTimeToLive = 1 * time.Hour
|
||||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
})
|
||||
|
||||
It("returns error when artist is not found", func() {
|
||||
artist, err := p.UpdateArtistInfo(ctx, "ar-not-found", 10, false)
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(artist).To(BeNil())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetSimilarArtists")
|
||||
})
|
||||
|
||||
It("populates info when artist exists but has no external info", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-existing",
|
||||
Name: "Test Artist",
|
||||
}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||
|
||||
expectedMBID := "mbid-artist-123"
|
||||
expectedBio := "Artist Bio"
|
||||
expectedURL := "http://artist.url"
|
||||
expectedImages := []agents.ExternalImage{
|
||||
{URL: "http://large.jpg", Size: 300},
|
||||
{URL: "http://medium.jpg", Size: 200},
|
||||
{URL: "http://small.jpg", Size: 100},
|
||||
}
|
||||
rawSimilar := []agents.Artist{
|
||||
{Name: "Similar Artist 1", MBID: "mbid-similar-1"},
|
||||
{Name: "Similar Artist 2", MBID: "mbid-similar-2"},
|
||||
{Name: "Similar Artist 3", MBID: "mbid-similar-3"},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-similar-2", Name: "Similar Artist 2"}
|
||||
|
||||
ag.On("GetArtistMBID", ctx, "ar-existing", "Test Artist").Return(expectedMBID, nil).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedImages, nil).Once()
|
||||
ag.On("GetArtistBiography", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedBio, nil).Once()
|
||||
ag.On("GetArtistURL", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedURL, nil).Once()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-existing", "Test Artist", expectedMBID, 100).Return(rawSimilar, nil).Once()
|
||||
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-existing", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal("ar-existing"))
|
||||
Expect(updatedArtist.MbzArtistID).To(Equal(expectedMBID))
|
||||
Expect(updatedArtist.Biography).To(Equal("Artist Bio"))
|
||||
Expect(updatedArtist.ExternalUrl).To(Equal(expectedURL))
|
||||
Expect(updatedArtist.LargeImageUrl).To(Equal("http://large.jpg"))
|
||||
Expect(updatedArtist.MediumImageUrl).To(Equal("http://medium.jpg"))
|
||||
Expect(updatedArtist.SmallImageUrl).To(Equal("http://small.jpg"))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).NotTo(BeNil())
|
||||
Expect(*updatedArtist.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-2"))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar Artist 2"))
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns cached info when artist exists and info is not expired", func() {
|
||||
now := time.Now()
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-cached",
|
||||
Name: "Cached Artist",
|
||||
MbzArtistID: "mbid-cached",
|
||||
ExternalUrl: "http://cached.url",
|
||||
Biography: "Cached Bio",
|
||||
LargeImageUrl: "http://cached_large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-similar-present", Name: "Similar Present"},
|
||||
{ID: "ar-similar-absent", Name: "Similar Absent"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-similar-present", Name: "Similar Present Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-cached", 5, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
|
||||
Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
|
||||
Expect(updatedArtist.MbzArtistID).To(Equal(originalArtist.MbzArtistID))
|
||||
Expect(updatedArtist.ExternalUrl).To(Equal(originalArtist.ExternalUrl))
|
||||
Expect(updatedArtist.Biography).To(Equal(originalArtist.Biography))
|
||||
Expect(updatedArtist.LargeImageUrl).To(Equal(originalArtist.LargeImageUrl))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
})
|
||||
|
||||
It("returns cached info and triggers background refresh when info is expired", func() {
|
||||
now := time.Now()
|
||||
expiredTime := now.Add(-conf.Server.DevArtistInfoTimeToLive * 2)
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-expired",
|
||||
Name: "Expired Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-exp-similar", Name: "Expired Similar"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-exp-similar", Name: "Expired Similar Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-expired", 5, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
|
||||
Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
})
|
||||
|
||||
It("includes non-present similar artists when includeNotPresent is true", func() {
|
||||
now := time.Now()
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-similar-test",
|
||||
Name: "Similar Test Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-sim-present", Name: "Similar Present"},
|
||||
{ID: "", Name: "Similar Absent Raw"},
|
||||
{ID: "ar-sim-absent-lookup", Name: "Similar Absent Lookup"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-sim-present", Name: "Similar Present Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-similar-test", 5, true)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(3))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
Expect(updatedArtist.SimilarArtists[1].ID).To(BeEmpty())
|
||||
Expect(updatedArtist.SimilarArtists[1].Name).To(Equal("Similar Absent Raw"))
|
||||
Expect(updatedArtist.SimilarArtists[2].ID).To(BeEmpty())
|
||||
Expect(updatedArtist.SimilarArtists[2].Name).To(Equal("Similar Absent Lookup"))
|
||||
})
|
||||
|
||||
It("updates ArtistInfo even if an optional agent call fails", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-agent-fail",
|
||||
Name: "Agent Fail Artist",
|
||||
}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||
|
||||
expectedErr := errors.New("agent MBID failed")
|
||||
ag.On("GetArtistMBID", ctx, "ar-agent-fail", "Agent Fail Artist").Return("", expectedErr).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||
ag.On("GetArtistBiography", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetArtistURL", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything, 100).Return(nil, nil).Maybe()
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-agent-fail", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,7 @@ func New() FFmpeg {
|
||||
}
|
||||
|
||||
const (
|
||||
extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -"
|
||||
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
||||
)
|
||||
|
||||
|
||||
37
core/lyrics/lyrics.go
Normal file
37
core/lyrics/lyrics.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
var lyricsList model.LyricList
|
||||
var err error
|
||||
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
lyricsList, err = fromEmbedded(ctx, mf)
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
lyricsList, err = fromExternalFile(ctx, mf, pattern)
|
||||
default:
|
||||
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
return lyricsList, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
17
core/lyrics/lyrics_suite_test.go
Normal file
17
core/lyrics/lyrics_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package lyrics_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestLyrics(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Lyrics Suite")
|
||||
}
|
||||
124
core/lyrics/lyrics_test.go
Normal file
124
core/lyrics/lyrics_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package lyrics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
const badLyrics = "This is a set of lyrics\nThat is not good"
|
||||
unsynced, _ := model.ToLyrics("xxx", badLyrics)
|
||||
embeddedLyrics := model.LyricList{*unsynced}
|
||||
|
||||
syncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
unsyncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
lyricsJson, _ := json.Marshal(embeddedLyrics)
|
||||
|
||||
mf = model.MediaFile{
|
||||
Lyrics: string(lyricsJson),
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
}
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
},
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
|
||||
|
||||
Context("Errors", func() {
|
||||
var RegularUserContext = XContext
|
||||
var isRegularUser = os.Getuid() != 0
|
||||
if isRegularUser {
|
||||
RegularUserContext = Context
|
||||
}
|
||||
|
||||
RegularUserContext("run without root permissions", func() {
|
||||
var accessForbiddenFile string
|
||||
|
||||
BeforeEach(func() {
|
||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||
|
||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mf.Path = accessForbiddenFile
|
||||
|
||||
DeferCleanup(func() {
|
||||
Expect(f.Close()).To(Succeed())
|
||||
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
It("should fallback to embedded if an error happens when parsing file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3,embedded"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
})
|
||||
|
||||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
51
core/lyrics/sources.go
Normal file
51
core/lyrics/sources.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
if mf.Lyrics != "" {
|
||||
log.Trace(ctx, "embedded lyrics found in file", "title", mf.Title)
|
||||
return mf.StructuredLyrics()
|
||||
}
|
||||
|
||||
log.Trace(ctx, "no embedded lyrics for file", "path", mf.Title)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
|
||||
basePath := mf.AbsolutePath()
|
||||
ext := path.Ext(basePath)
|
||||
|
||||
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
|
||||
|
||||
contents, err := os.ReadFile(externalLyric)
|
||||
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lyrics, err := model.ToLyrics("xxx", string(contents))
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
|
||||
return nil, err
|
||||
} else if lyrics == nil {
|
||||
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
|
||||
|
||||
return model.LyricList{*lyrics}, nil
|
||||
}
|
||||
112
core/lyrics/sources_test.go
Normal file
112
core/lyrics/sources_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
Describe("fromEmbedded", func() {
|
||||
It("should return nothing for a media file with no lyrics", func() {
|
||||
mf := model.MediaFile{}
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return lyrics for a media file with well-formatted lyrics", func() {
|
||||
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
||||
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
||||
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
|
||||
expectedList := model.LyricList{*synced, *unsynced}
|
||||
lyricsJson, err := json.Marshal(expectedList)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mf := model.MediaFile{
|
||||
Lyrics: string(lyricsJson),
|
||||
}
|
||||
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(Equal(expectedList))
|
||||
})
|
||||
|
||||
It("should return an error if somehow the JSON is bad", func() {
|
||||
mf := model.MediaFile{Lyrics: "["}
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromExternalFile", func() {
|
||||
It("should return nil for lyrics that don't exist", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return synchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should return unsynchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -61,9 +60,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, name)
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
s = newBufferedScrobbler(ds, s, name)
|
||||
}
|
||||
s = newBufferedScrobbler(ds, s, name)
|
||||
p.scrobblers[name] = s
|
||||
}
|
||||
log.Debug("List of scrobblers enabled", "names", enabled)
|
||||
@@ -183,11 +180,7 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile,
|
||||
if !s.IsAuthorized(ctx, u.ID) {
|
||||
continue
|
||||
}
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
} else {
|
||||
log.Debug(ctx, "Sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
}
|
||||
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
err := s.Scrobble(ctx, u.ID, scrobble)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -27,9 +26,6 @@ var _ = Describe("PlayTracker", func() {
|
||||
var fake fakeScrobbler
|
||||
|
||||
BeforeEach(func() {
|
||||
// Remove buffering to simplify tests
|
||||
conf.Server.DevEnableBufferedScrobble = false
|
||||
|
||||
ctx = context.Background()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
@@ -42,6 +38,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
return nil
|
||||
})
|
||||
tracker = newPlayTracker(ds, events.GetBroker())
|
||||
tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests
|
||||
|
||||
track = model.MediaFile{
|
||||
ID: "123",
|
||||
@@ -239,7 +236,6 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BFR This is duplicated in a few places
|
||||
func _p(id, name string, sortName ...string) model.Participant {
|
||||
p := model.Participant{Artist: model.Artist{ID: id, Name: name}}
|
||||
if len(sortName) > 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
@@ -13,11 +14,12 @@ var Set = wire.NewSet(
|
||||
NewMediaStreamer,
|
||||
GetTranscodingCache,
|
||||
NewArchiver,
|
||||
NewExternalMetadata,
|
||||
NewPlayers,
|
||||
NewShare,
|
||||
NewPlaylists,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
ffmpeg.New,
|
||||
scrobbler.GetPlayTracker,
|
||||
playback.GetInstance,
|
||||
|
||||
@@ -164,7 +164,9 @@ join library on media_file.library_id = library.id`, string(os.PathSeparator)))
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, "insert into folder (id, library_id, path, name, parent_id) values (?, ?, ?, ?, ?)")
|
||||
stmt, err := tx.PrepareContext(ctx,
|
||||
"insert into folder (id, library_id, path, name, parent_id, updated_at) values (?, ?, ?, ?, ?, '0000-00-00 00:00:00')",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -172,14 +174,20 @@ join library on media_file.library_id = library.id`, string(os.PathSeparator)))
|
||||
// Finally, walk the in-mem filesystem and insert all folders into the DB.
|
||||
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
// Don't abort the walk, just log the error
|
||||
log.Error("error walking folder to DB", "path", path, err)
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
f := model.NewFolder(lib, path)
|
||||
_, err = stmt.ExecContext(ctx, f.ID, lib.ID, f.Path, f.Name, f.ParentID)
|
||||
if err != nil {
|
||||
log.Error("Error writing folder to DB", "path", path, err)
|
||||
}
|
||||
// Skip entries that are not directories
|
||||
if !d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a folder in the DB
|
||||
f := model.NewFolder(lib, path)
|
||||
_, err = stmt.ExecContext(ctx, f.ID, lib.ID, f.Path, f.Name, f.ParentID)
|
||||
if err != nil {
|
||||
log.Error("error writing folder to DB", "path", path, err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
@@ -187,7 +195,12 @@ join library on media_file.library_id = library.id`, string(os.PathSeparator)))
|
||||
return fmt.Errorf("error populating folder table: %w", err)
|
||||
}
|
||||
|
||||
libPathLen := utf8.RuneCountInString(lib.Path)
|
||||
// Count the number of characters in the library path
|
||||
libPath := filepath.Clean(lib.Path)
|
||||
libPathLen := utf8.RuneCountInString(libPath)
|
||||
|
||||
// In one go, update all paths in the media_file table, removing the library path prefix
|
||||
// and replacing any backslashes with slashes (the path separator used by the io/fs package)
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
|
||||
update media_file set path = replace(substr(path, %d), '\', '/');`, libPathLen+2))
|
||||
if err != nil {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# This script does not handle file names that contain spaces.
|
||||
|
||||
gofmtcmd="go run golang.org/x/tools/cmd/goimports@latest"
|
||||
gofmtcmd="go tool goimports"
|
||||
|
||||
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$')
|
||||
[ -z "$gofiles" ] && exit 0
|
||||
|
||||
66
go.mod
66
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.23.4
|
||||
go 1.24.2
|
||||
|
||||
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
|
||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
@@ -24,8 +24,9 @@ require (
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-chi/httprate v0.14.1
|
||||
github.com/go-chi/jwtauth/v5 v5.3.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||
github.com/go-viper/encoding/ini v0.1.1
|
||||
github.com/gohugoio/hashstructure v0.5.0
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -36,31 +37,31 @@ require (
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/mattn/go-sqlite3 v1.14.27
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.23.0
|
||||
github.com/onsi/gomega v1.36.2
|
||||
github.com/pelletier/go-toml/v2 v2.2.3
|
||||
github.com/onsi/ginkgo/v2 v2.23.4
|
||||
github.com/onsi/gomega v1.37.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.24.1
|
||||
github.com/pressly/goose/v3 v3.24.2
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
|
||||
golang.org/x/image v0.25.0
|
||||
golang.org/x/net v0.37.0
|
||||
golang.org/x/sync v0.12.0
|
||||
golang.org/x/sys v0.31.0
|
||||
golang.org/x/text v0.23.0
|
||||
golang.org/x/image v0.26.0
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/sync v0.13.0
|
||||
golang.org/x/sys v0.32.0
|
||||
golang.org/x/text v0.24.0
|
||||
golang.org/x/time v0.11.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -68,20 +69,25 @@ require (
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/reflex v0.3.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/creack/pty v1.1.11 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 // indirect
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // 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
|
||||
@@ -90,29 +96,37 @@ require (
|
||||
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.9 // 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/ogier/pflag v0.0.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.12.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/cespare/reflex
|
||||
github.com/google/wire/cmd/wire
|
||||
github.com/onsi/ginkgo/v2/ginkgo
|
||||
golang.org/x/tools/cmd/goimports
|
||||
)
|
||||
|
||||
151
go.sum
151
go.sum
@@ -14,10 +14,14 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
|
||||
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
|
||||
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
|
||||
github.com/creack/pty v1.1.11/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=
|
||||
@@ -48,23 +52,28 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
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.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.2 h1:s+ON3ATyyMs3Me0kqyuua6Rwu+2zqIIkL0GCaMarwvs=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.2/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
||||
github.com/go-logr/logr v1.4.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-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
|
||||
github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
|
||||
@@ -76,8 +85,9 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 h1:+J3r2e8+RsmN3vKfo75g0YSY61ms37qzPglu4p0sGro=
|
||||
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -92,11 +102,6 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
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/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
|
||||
@@ -105,11 +110,18 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
|
||||
github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -130,48 +142,48 @@ github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is=
|
||||
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.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
|
||||
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
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.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
|
||||
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/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.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ=
|
||||
github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
|
||||
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
|
||||
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
|
||||
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/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/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.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
|
||||
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
|
||||
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
|
||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||
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.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
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/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
||||
@@ -184,10 +196,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
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.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
@@ -201,16 +211,16 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
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.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
@@ -229,6 +239,12 @@ github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQ
|
||||
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=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
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=
|
||||
@@ -240,19 +256,21 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
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/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -265,8 +283,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -274,8 +292,9 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -293,8 +312,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
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=
|
||||
@@ -315,8 +334,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -331,8 +350,8 @@ golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -344,17 +363,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/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.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
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.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
|
||||
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
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=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
|
||||
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
|
||||
|
||||
@@ -17,7 +17,7 @@ type Album struct {
|
||||
Name string `structs:"name" json:"name"`
|
||||
EmbedArtPath string `structs:"embed_art_path" json:"-"`
|
||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants
|
||||
// BFR Rename to AlbumArtistDisplayName
|
||||
// AlbumArtist is the display name used for the album artist.
|
||||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||
MaxYear int `structs:"max_year" json:"maxYear"`
|
||||
MinYear int `structs:"min_year" json:"minYear"`
|
||||
|
||||
@@ -46,7 +46,6 @@ var _ = Describe("Operators", func() {
|
||||
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+
|
||||
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
||||
|
||||
// TODO These may be flaky
|
||||
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
|
||||
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ var (
|
||||
// Should either be at the beginning of file, or beginning of line
|
||||
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
|
||||
timeRegex = regexp.MustCompile(timeRegexString)
|
||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset):([^]]+)]`)
|
||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
|
||||
)
|
||||
|
||||
func (l Lyrics) IsEmpty() bool {
|
||||
@@ -72,6 +72,8 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
||||
switch idTag[1] {
|
||||
case "ar":
|
||||
artist = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
case "lang":
|
||||
language = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
case "offset":
|
||||
{
|
||||
off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64)
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
var _ = Describe("ToLyrics", func() {
|
||||
It("should parse tags with spaces", func() {
|
||||
num := int64(1551)
|
||||
lyrics, err := ToLyrics("xxx", "[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
|
||||
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Lang).To(Equal("eng"))
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.DisplayArtist).To(Equal("An artist"))
|
||||
Expect(lyrics.DisplayTitle).To(Equal("A title"))
|
||||
|
||||
@@ -31,10 +31,10 @@ type MediaFile struct {
|
||||
Title string `structs:"title" json:"title"`
|
||||
Album string `structs:"album" json:"album"`
|
||||
ArtistID string `structs:"artist_id" json:"artistId"` // Deprecated: Use Participants instead
|
||||
// BFR Rename to ArtistDisplayName
|
||||
// Artist is the display name used for the artist.
|
||||
Artist string `structs:"artist" json:"artist"`
|
||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
|
||||
// BFR Rename to AlbumArtistDisplayName
|
||||
// AlbumArtist is the display name used for the album artist.
|
||||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||
AlbumID string `structs:"album_id" json:"albumId"`
|
||||
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
|
||||
@@ -183,6 +183,8 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
|
||||
|
||||
a.Missing = true
|
||||
embedArtPath := ""
|
||||
embedArtDisc := 0
|
||||
for _, m := range mfs {
|
||||
// We assume these attributes are all the same for all songs in an album
|
||||
a.ID = m.AlbumID
|
||||
@@ -211,15 +213,15 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
comments = append(comments, m.Comment)
|
||||
mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID)
|
||||
mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID)
|
||||
if m.HasCoverArt && a.EmbedArtPath == "" {
|
||||
a.EmbedArtPath = m.Path
|
||||
}
|
||||
if m.DiscNumber > 0 {
|
||||
a.Discs.Add(m.DiscNumber, m.DiscSubtitle)
|
||||
}
|
||||
tags = append(tags, m.Tags.FlattenAll()...)
|
||||
a.Participants.Merge(m.Participants)
|
||||
|
||||
// Find the MediaFile with cover art and the lowest disc number to use for album cover
|
||||
embedArtPath, embedArtDisc = firstArtPath(embedArtPath, embedArtDisc, m)
|
||||
|
||||
if m.ExplicitStatus == "c" && a.ExplicitStatus != "e" {
|
||||
a.ExplicitStatus = "c"
|
||||
} else if m.ExplicitStatus == "e" {
|
||||
@@ -231,6 +233,7 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
a.Missing = a.Missing && m.Missing
|
||||
}
|
||||
|
||||
a.EmbedArtPath = embedArtPath
|
||||
a.SetTags(tags)
|
||||
a.FolderIDs = slice.Unique(slice.Map(mfs, func(m MediaFile) string { return m.FolderID }))
|
||||
a.Date, _ = allOrNothing(dates)
|
||||
@@ -305,6 +308,28 @@ func fixAlbumArtist(a *Album) {
|
||||
}
|
||||
}
|
||||
|
||||
// firstArtPath determines which media file path should be used for album artwork
|
||||
// based on disc number (preferring lower disc numbers) and path (for consistency)
|
||||
func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int) {
|
||||
if !m.HasCoverArt {
|
||||
return currentPath, currentDisc
|
||||
}
|
||||
|
||||
// If current has no disc number (currentDisc == 0) or new file has lower disc number
|
||||
if currentDisc == 0 || (m.DiscNumber < currentDisc && m.DiscNumber > 0) {
|
||||
return m.Path, m.DiscNumber
|
||||
}
|
||||
|
||||
// If disc numbers are equal, use path for ordering
|
||||
if m.DiscNumber == currentDisc {
|
||||
if m.Path < currentPath || currentPath == "" {
|
||||
return m.Path, m.DiscNumber
|
||||
}
|
||||
}
|
||||
|
||||
return currentPath, currentDisc
|
||||
}
|
||||
|
||||
type MediaFileCursor iter.Seq2[MediaFile, error]
|
||||
|
||||
type MediaFileRepository interface {
|
||||
|
||||
@@ -305,6 +305,101 @@ var _ = Describe("MediaFiles", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Album Art", func() {
|
||||
When("we have media files with cover art from multiple discs", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/Disc2/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc1/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc3/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 3,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the cover art from the lowest disc number", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have media files with cover art from the same disc number", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/Disc1/02.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc1/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the cover art with the lowest path alphabetically", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have media files with some missing cover art", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/Disc1/01.mp3",
|
||||
HasCoverArt: false,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc2/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the file with cover art even if from a higher disc number", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc2/01.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have media files with path names that don't correlate with disc numbers", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/file-z.mp3", // Path would be sorted last alphabetically
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1, // But it has lowest disc number
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/file-a.mp3", // Path would be sorted first alphabetically
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 2, // But it has higher disc number
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/file-m.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 3,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the cover art from the lowest disc number regardless of path", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/file-z.mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,20 +51,6 @@ func legacyMapAlbumName(md Metadata) string {
|
||||
|
||||
// Keep the TaggedLikePicard logic for backwards compatibility
|
||||
func legacyReleaseDate(md Metadata) string {
|
||||
// Start with defaults
|
||||
date := md.Date(model.TagRecordingDate)
|
||||
year := date.Year()
|
||||
originalDate := md.Date(model.TagOriginalDate)
|
||||
originalYear := originalDate.Year()
|
||||
releaseDate := md.Date(model.TagReleaseDate)
|
||||
releaseYear := releaseDate.Year()
|
||||
|
||||
// MusicBrainz Picard writes the Release Date of an album to the Date tag, and leaves the Release Date tag empty
|
||||
taggedLikePicard := (originalYear != 0) &&
|
||||
(releaseYear == 0) &&
|
||||
(year >= originalYear)
|
||||
if taggedLikePicard {
|
||||
return string(date)
|
||||
}
|
||||
_, _, releaseDate := md.mapDates()
|
||||
return string(releaseDate)
|
||||
}
|
||||
|
||||
30
model/metadata/legacy_ids_test.go
Normal file
30
model/metadata/legacy_ids_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("legacyReleaseDate", func() {
|
||||
|
||||
DescribeTable("legacyReleaseDate",
|
||||
func(recordingDate, originalDate, releaseDate, expected string) {
|
||||
md := New("", Info{
|
||||
Tags: map[string][]string{
|
||||
"DATE": {recordingDate},
|
||||
"ORIGINALDATE": {originalDate},
|
||||
"RELEASEDATE": {releaseDate},
|
||||
},
|
||||
})
|
||||
|
||||
result := legacyReleaseDate(md)
|
||||
Expect(result).To(Equal(expected))
|
||||
},
|
||||
Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
|
||||
Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
|
||||
Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
|
||||
Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
|
||||
Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
|
||||
Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"math"
|
||||
@@ -39,11 +40,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
mf.ExplicitStatus = md.mapExplicitStatusTag()
|
||||
|
||||
// Dates
|
||||
origDate := md.Date(model.TagOriginalDate)
|
||||
date, origDate, relDate := md.mapDates()
|
||||
mf.OriginalYear, mf.OriginalDate = origDate.Year(), string(origDate)
|
||||
relDate := md.Date(model.TagReleaseDate)
|
||||
mf.ReleaseYear, mf.ReleaseDate = relDate.Year(), string(relDate)
|
||||
date := md.Date(model.TagRecordingDate)
|
||||
mf.Year, mf.Date = date.Year(), string(date)
|
||||
|
||||
// MBIDs
|
||||
@@ -51,6 +50,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID)
|
||||
mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID)
|
||||
mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID)
|
||||
mf.MbzAlbumType = md.String(model.TagReleaseType)
|
||||
|
||||
// ReplayGain
|
||||
mf.RGAlbumPeak = md.Float(model.TagReplayGainAlbumPeak, 1)
|
||||
@@ -72,7 +72,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
mf.UpdatedAt = md.ModTime()
|
||||
|
||||
mf.Participants = md.mapParticipants()
|
||||
mf.Artist = md.mapDisplayArtist(mf)
|
||||
mf.Artist = md.mapDisplayArtist()
|
||||
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
|
||||
|
||||
// Persistent IDs
|
||||
@@ -164,3 +164,22 @@ func (md Metadata) mapExplicitStatusTag() string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (md Metadata) mapDates() (date Date, originalDate Date, releaseDate Date) {
|
||||
// Start with defaults
|
||||
date = md.Date(model.TagRecordingDate)
|
||||
originalDate = md.Date(model.TagOriginalDate)
|
||||
releaseDate = md.Date(model.TagReleaseDate)
|
||||
|
||||
// For some historic reason, taggers have been writing the Release Date of an album to the Date tag,
|
||||
// and leave the Release Date tag empty.
|
||||
legacyMappings := (originalDate != "") &&
|
||||
(releaseDate == "") &&
|
||||
(date >= originalDate)
|
||||
if legacyMappings {
|
||||
return originalDate, originalDate, date
|
||||
}
|
||||
// when there's no Date, first fall back to Original Date, then to Release Date.
|
||||
date = cmp.Or(date, originalDate, releaseDate)
|
||||
return date, originalDate, releaseDate
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ var _ = Describe("ToMediaFile", func() {
|
||||
}
|
||||
|
||||
Describe("Dates", func() {
|
||||
It("should parse the dates like Picard", func() {
|
||||
It("should parse properly tagged dates ", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ORIGINALDATE": {"1978-09-10"},
|
||||
"DATE": {"1977-03-04"},
|
||||
@@ -49,6 +49,32 @@ var _ = Describe("ToMediaFile", func() {
|
||||
Expect(mf.ReleaseYear).To(Equal(2002))
|
||||
Expect(mf.ReleaseDate).To(Equal("2002-01-02"))
|
||||
})
|
||||
|
||||
It("should parse dates with only year", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ORIGINALYEAR": {"1978"},
|
||||
"DATE": {"1977"},
|
||||
"RELEASEDATE": {"2002"},
|
||||
})
|
||||
|
||||
Expect(mf.Year).To(Equal(1977))
|
||||
Expect(mf.Date).To(Equal("1977"))
|
||||
Expect(mf.OriginalYear).To(Equal(1978))
|
||||
Expect(mf.OriginalDate).To(Equal("1978"))
|
||||
Expect(mf.ReleaseYear).To(Equal(2002))
|
||||
Expect(mf.ReleaseDate).To(Equal("2002"))
|
||||
})
|
||||
|
||||
It("should parse dates tagged the legacy way (no release date)", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"DATE": {"2014"},
|
||||
"ORIGINALDATE": {"1966"},
|
||||
})
|
||||
|
||||
Expect(mf.Year).To(Equal(1966))
|
||||
Expect(mf.OriginalYear).To(Equal(1966))
|
||||
Expect(mf.ReleaseYear).To(Equal(2014))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Lyrics", func() {
|
||||
|
||||
@@ -2,7 +2,9 @@ package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
@@ -175,7 +177,11 @@ func (md Metadata) getRoleValues(role model.TagName) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
if conf := model.TagRolesConf(); len(conf.Split) > 0 {
|
||||
conf := model.TagMainMappings()[role]
|
||||
if conf.Split == nil {
|
||||
conf = model.TagRolesConf()
|
||||
}
|
||||
if len(conf.Split) > 0 {
|
||||
values = conf.SplitTagValue(values)
|
||||
return filterDuplicatedOrEmptyValues(values)
|
||||
}
|
||||
@@ -192,39 +198,39 @@ func (md Metadata) getArtistValues(single, multi model.TagName) []string {
|
||||
if len(vSingle) != 1 {
|
||||
return vSingle
|
||||
}
|
||||
if conf := model.TagArtistsConf(); len(conf.Split) > 0 {
|
||||
conf := model.TagMainMappings()[single]
|
||||
if conf.Split == nil {
|
||||
conf = model.TagArtistsConf()
|
||||
}
|
||||
if len(conf.Split) > 0 {
|
||||
vSingle = conf.SplitTagValue(vSingle)
|
||||
return filterDuplicatedOrEmptyValues(vSingle)
|
||||
}
|
||||
return vSingle
|
||||
}
|
||||
|
||||
func (md Metadata) getTags(tagNames ...model.TagName) []string {
|
||||
for _, tagName := range tagNames {
|
||||
values := md.Strings(tagName)
|
||||
if len(values) > 0 {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (md Metadata) mapDisplayRole(mf model.MediaFile, role model.Role, tagNames ...model.TagName) string {
|
||||
artistNames := md.getTags(tagNames...)
|
||||
values := []string{
|
||||
"",
|
||||
mf.Participants.First(role).Name,
|
||||
consts.UnknownArtist,
|
||||
}
|
||||
if len(artistNames) == 1 {
|
||||
values[0] = artistNames[0]
|
||||
}
|
||||
return cmp.Or(values...)
|
||||
func (md Metadata) mapDisplayName(singularTagName, pluralTagName model.TagName) string {
|
||||
return cmp.Or(
|
||||
strings.Join(md.tags[singularTagName], conf.Server.Scanner.ArtistJoiner),
|
||||
strings.Join(md.tags[pluralTagName], conf.Server.Scanner.ArtistJoiner),
|
||||
)
|
||||
}
|
||||
|
||||
func (md Metadata) mapDisplayArtist(mf model.MediaFile) string {
|
||||
return md.mapDisplayRole(mf, model.RoleArtist, model.TagTrackArtist, model.TagTrackArtists)
|
||||
func (md Metadata) mapDisplayArtist() string {
|
||||
return cmp.Or(
|
||||
md.mapDisplayName(model.TagTrackArtist, model.TagTrackArtists),
|
||||
consts.UnknownArtist,
|
||||
)
|
||||
}
|
||||
|
||||
func (md Metadata) mapDisplayAlbumArtist(mf model.MediaFile) string {
|
||||
return md.mapDisplayRole(mf, model.RoleAlbumArtist, model.TagAlbumArtist, model.TagAlbumArtists)
|
||||
fallbackName := consts.UnknownArtist
|
||||
if md.Bool(model.TagCompilation) {
|
||||
fallbackName = consts.VariousArtists
|
||||
}
|
||||
return cmp.Or(
|
||||
md.mapDisplayName(model.TagAlbumArtist, model.TagAlbumArtists),
|
||||
mf.Participants.First(model.RoleAlbumArtist).Name,
|
||||
fallbackName,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ var _ = Describe("Participants", func() {
|
||||
mf = toMediaFile(model.RawTags{})
|
||||
})
|
||||
|
||||
It("should set the display name to Unknown Artist", func() {
|
||||
Expect(mf.Artist).To(Equal("[Unknown Artist]"))
|
||||
})
|
||||
|
||||
It("should set artist to Unknown Artist", func() {
|
||||
Expect(mf.Artist).To(Equal("[Unknown Artist]"))
|
||||
})
|
||||
@@ -92,6 +96,7 @@ var _ = Describe("Participants", func() {
|
||||
Expect(artist.MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Multiple values in a Single-valued ARTIST tags, no ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
@@ -101,12 +106,13 @@ var _ = Describe("Participants", func() {
|
||||
})
|
||||
})
|
||||
|
||||
It("should split the tag", func() {
|
||||
By("keeping the first artist as the display name")
|
||||
It("should use the full string as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("Artist Name feat. Someone Else"))
|
||||
Expect(mf.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(mf.OrderArtistName).To(Equal("artist name"))
|
||||
})
|
||||
|
||||
It("should split the tag", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
@@ -130,6 +136,7 @@ var _ = Describe("Participants", func() {
|
||||
Expect(artist1.SortArtistName).To(Equal("Else, Someone"))
|
||||
Expect(artist1.MbzArtistID).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should split the tag using case-insensitive separators", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"A1 FEAT. A2"},
|
||||
@@ -167,8 +174,8 @@ var _ = Describe("Participants", func() {
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the first artist name as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist"))
|
||||
It("should concatenate all ARTIST values as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist • Second Artist"))
|
||||
})
|
||||
|
||||
It("should populate the participants with all artists", func() {
|
||||
@@ -194,6 +201,101 @@ var _ = Describe("Participants", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("Single-valued ARTIST tag, single-valued ARTISTS tag, same values", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name"},
|
||||
"ARTISTS": {"Artist Name"},
|
||||
"ARTISTSORT": {"Name, Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the ARTIST tag as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("should populate the participants with the ARTIST", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(1)),
|
||||
))
|
||||
|
||||
artist := participants[model.RoleArtist][0]
|
||||
Expect(artist.ID).ToNot(BeEmpty())
|
||||
Expect(artist.Name).To(Equal("Artist Name"))
|
||||
Expect(artist.OrderArtistName).To(Equal("artist name"))
|
||||
Expect(artist.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(artist.MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Single-valued ARTIST tag, single-valued ARTISTS tag, different values", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name"},
|
||||
"ARTISTS": {"Artist Name 2"},
|
||||
"ARTISTSORT": {"Name, Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the ARTIST tag as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("should use only artists from ARTISTS", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(1)),
|
||||
))
|
||||
|
||||
artist := participants[model.RoleArtist][0]
|
||||
Expect(artist.ID).ToNot(BeEmpty())
|
||||
Expect(artist.Name).To(Equal("Artist Name 2"))
|
||||
Expect(artist.OrderArtistName).To(Equal("artist name 2"))
|
||||
Expect(artist.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(artist.MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("No ARTIST tag, multi-valued ARTISTS tag", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTISTS": {"First Artist", "Second Artist"},
|
||||
"ARTISTSSORT": {"Name, First Artist", "Name, Second Artist"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should concatenate ARTISTS as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist • Second Artist"))
|
||||
})
|
||||
|
||||
It("should populate the participants with all artists", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
|
||||
artist0 := participants[model.RoleArtist][0]
|
||||
Expect(artist0.ID).ToNot(BeEmpty())
|
||||
Expect(artist0.Name).To(Equal("First Artist"))
|
||||
Expect(artist0.OrderArtistName).To(Equal("first artist"))
|
||||
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
|
||||
Expect(artist0.MbzArtistID).To(BeEmpty())
|
||||
|
||||
artist1 := participants[model.RoleArtist][1]
|
||||
Expect(artist1.ID).ToNot(BeEmpty())
|
||||
Expect(artist1.Name).To(Equal("Second Artist"))
|
||||
Expect(artist1.OrderArtistName).To(Equal("second artist"))
|
||||
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
|
||||
Expect(artist1.MbzArtistID).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Single-valued ARTIST tags, multi-valued ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
@@ -231,6 +333,7 @@ var _ = Describe("Participants", func() {
|
||||
})
|
||||
})
|
||||
|
||||
// Not a good tagging strategy, but supported anyway.
|
||||
Context("Multi-valued ARTIST tags, multi-valued ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
@@ -242,13 +345,8 @@ var _ = Describe("Participants", func() {
|
||||
})
|
||||
})
|
||||
|
||||
XIt("should use the values concatenated as a display name ", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist + Second Artist"))
|
||||
})
|
||||
|
||||
// TODO: remove when the above is implemented
|
||||
It("should use the first artist name as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist 2"))
|
||||
It("should use ARTIST values concatenated as a display name ", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist • Second Artist"))
|
||||
})
|
||||
|
||||
It("should prioritize ARTISTS tags", func() {
|
||||
@@ -275,6 +373,7 @@ var _ = Describe("Participants", func() {
|
||||
})
|
||||
|
||||
Describe("ALBUMARTIST(S) tags", func() {
|
||||
// Only test specific scenarios for ALBUMARTIST(S) tags, as the logic is the same as for ARTIST(S) tags.
|
||||
Context("No ALBUMARTIST/ALBUMARTISTS tags", func() {
|
||||
When("the COMPILATION tag is not set", func() {
|
||||
BeforeEach(func() {
|
||||
@@ -305,6 +404,35 @@ var _ = Describe("Participants", func() {
|
||||
})
|
||||
})
|
||||
|
||||
When("the COMPILATION tag is not set and there is no ALBUMARTIST tag", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name", "Another Artist"},
|
||||
"ARTISTSORT": {"Name, Artist", "Artist, Another"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the first ARTIST as ALBUMARTIST", func() {
|
||||
Expect(mf.AlbumArtist).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("should add the ARTIST to participants as ALBUMARTIST", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2))
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(2)),
|
||||
))
|
||||
|
||||
albumArtist := participants[model.RoleAlbumArtist][0]
|
||||
Expect(albumArtist.Name).To(Equal("Artist Name"))
|
||||
Expect(albumArtist.SortArtistName).To(Equal("Name, Artist"))
|
||||
|
||||
albumArtist = participants[model.RoleAlbumArtist][1]
|
||||
Expect(albumArtist.Name).To(Equal("Another Artist"))
|
||||
Expect(albumArtist.SortArtistName).To(Equal("Artist, Another"))
|
||||
})
|
||||
})
|
||||
|
||||
When("the COMPILATION tag is true", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
@@ -331,6 +459,19 @@ var _ = Describe("Participants", func() {
|
||||
Expect(albumArtist.MbzArtistID).To(Equal(consts.VariousArtistsMbzId))
|
||||
})
|
||||
})
|
||||
|
||||
When("the COMPILATION tag is true and there are ALBUMARTIST tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPILATION": {"1"},
|
||||
"ALBUMARTIST": {"Album Artist Name 1", "Album Artist Name 2"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the ALBUMARTIST names as display name", func() {
|
||||
Expect(mf.AlbumArtist).To(Equal("Album Artist Name 1 • Album Artist Name 2"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("ALBUMARTIST tag is set", func() {
|
||||
|
||||
@@ -120,7 +120,7 @@ func (md Metadata) first(key model.TagName) string {
|
||||
|
||||
func float(value string, def ...float64) float64 {
|
||||
v, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || v == math.Inf(-1) || v == math.Inf(1) {
|
||||
if err != nil || v == math.Inf(-1) || math.IsInf(v, 1) || math.IsNaN(v) {
|
||||
if len(def) > 0 {
|
||||
return def[0]
|
||||
}
|
||||
|
||||
@@ -90,13 +90,14 @@ var _ = Describe("Metadata", func() {
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(SatisfyAll(
|
||||
HaveLen(5),
|
||||
Not(HaveKey(unknownTag)),
|
||||
HaveKeyWithValue(model.TagTrackArtist, []string{"Artist Name", "Second Artist"}),
|
||||
HaveKeyWithValue(model.TagAlbum, []string{"Album Name"}),
|
||||
HaveKeyWithValue(model.TagRecordingDate, []string{"2022-10-02", "2022"}),
|
||||
HaveKeyWithValue(model.TagRecordingDate, []string{"2022-10-02"}),
|
||||
HaveKeyWithValue(model.TagReleaseDate, []string{"2022"}),
|
||||
HaveKeyWithValue(model.TagGenre, []string{"Pop", "Rock"}),
|
||||
HaveKeyWithValue(model.TagTrackNumber, []string{"1/10"}),
|
||||
HaveLen(6),
|
||||
))
|
||||
})
|
||||
|
||||
@@ -264,6 +265,7 @@ var _ = Describe("Metadata", func() {
|
||||
Entry("1.2dB", "1.2dB", 1.2),
|
||||
Entry("Infinity", "Infinity", 0.0),
|
||||
Entry("Invalid value", "INVALID VALUE", 0.0),
|
||||
Entry("NaN", "NaN", 0.0),
|
||||
)
|
||||
DescribeTable("Peak",
|
||||
func(tagValue string, expected float64) {
|
||||
@@ -275,6 +277,7 @@ var _ = Describe("Metadata", func() {
|
||||
Entry("Invalid dB suffix", "0.7dB", 1.0),
|
||||
Entry("Infinity", "Infinity", 1.0),
|
||||
Entry("Invalid value", "INVALID VALUE", 1.0),
|
||||
Entry("NaN", "NaN", 1.0),
|
||||
)
|
||||
DescribeTable("getR128GainValue",
|
||||
func(tagValue string, expected float64) {
|
||||
|
||||
@@ -28,5 +28,4 @@ type PlayerRepository interface {
|
||||
Put(p *Player) error
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
CountByClient(...QueryOptions) (map[string]int64, error)
|
||||
// TODO: Add CountAll method. Useful at least for metrics.
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
@@ -55,6 +56,7 @@ func (c TagConf) SplitTagValue(values []string) []string {
|
||||
type TagType string
|
||||
|
||||
const (
|
||||
TagTypeString TagType = "string"
|
||||
TagTypeInteger TagType = "int"
|
||||
TagTypeFloat TagType = "float"
|
||||
TagTypeDate TagType = "date"
|
||||
@@ -113,8 +115,9 @@ func collectTags(tagMappings, normalized map[TagName]TagConf) {
|
||||
aliases = append(aliases, strings.ToLower(val))
|
||||
}
|
||||
if v.Split != nil {
|
||||
if v.Type != "" {
|
||||
log.Error("Tag splitting only available for string types", "tag", k, "split", v.Split, "type", v.Type)
|
||||
if v.Type != "" && v.Type != TagTypeString {
|
||||
log.Error("Tag splitting only available for string types", "tag", k, "split", v.Split,
|
||||
"type", string(v.Type))
|
||||
v.Split = nil
|
||||
} else {
|
||||
v.SplitRx = compileSplitRegex(k, v.Split)
|
||||
@@ -173,21 +176,42 @@ func loadTagMappings() {
|
||||
log.Error("No tag mappings found in mappings.yaml, check the format")
|
||||
}
|
||||
|
||||
// Use Scanner.GenreSeparators if specified and Tags.genre is not defined
|
||||
if conf.Server.Scanner.GenreSeparators != "" && len(conf.Server.Tags["genre"].Aliases) == 0 {
|
||||
genreConf := _mappings.Main[TagName("genre")]
|
||||
genreConf.Split = strings.Split(conf.Server.Scanner.GenreSeparators, "")
|
||||
genreConf.SplitRx = compileSplitRegex("genre", genreConf.Split)
|
||||
_mappings.Main[TagName("genre")] = genreConf
|
||||
log.Debug("Loading deprecated list of genre separators", "separators", genreConf.Split)
|
||||
}
|
||||
|
||||
// Overwrite the default mappings with the ones from the config
|
||||
for tag, cfg := range conf.Server.Tags {
|
||||
if len(cfg.Aliases) == 0 {
|
||||
if cfg.Ignore {
|
||||
delete(_mappings.Main, TagName(tag))
|
||||
delete(_mappings.Additional, TagName(tag))
|
||||
continue
|
||||
}
|
||||
c := TagConf{
|
||||
Aliases: cfg.Aliases,
|
||||
Type: TagType(cfg.Type),
|
||||
MaxLength: cfg.MaxLength,
|
||||
Split: cfg.Split,
|
||||
Album: cfg.Album,
|
||||
SplitRx: compileSplitRegex(TagName(tag), cfg.Split),
|
||||
oldValue, ok := _mappings.Main[TagName(tag)]
|
||||
if !ok {
|
||||
oldValue = _mappings.Additional[TagName(tag)]
|
||||
}
|
||||
aliases := cfg.Aliases
|
||||
if len(aliases) == 0 {
|
||||
aliases = oldValue.Aliases
|
||||
}
|
||||
split := cfg.Split
|
||||
if split == nil {
|
||||
split = oldValue.Split
|
||||
}
|
||||
c := TagConf{
|
||||
Aliases: aliases,
|
||||
Split: split,
|
||||
Type: cmp.Or(TagType(cfg.Type), oldValue.Type),
|
||||
MaxLength: cmp.Or(cfg.MaxLength, oldValue.MaxLength),
|
||||
Album: cmp.Or(cfg.Album, oldValue.Album),
|
||||
}
|
||||
c.SplitRx = compileSplitRegex(TagName(tag), c.Split)
|
||||
if _, ok := _mappings.Main[TagName(tag)]; ok {
|
||||
_mappings.Main[TagName(tag)] = c
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -96,9 +97,10 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
r.tableName = "album"
|
||||
r.registerModel(&model.Album{}, albumFilters())
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_album_name, order_album_artist_name",
|
||||
"artist": "compilation, order_album_artist_name, order_album_name",
|
||||
"album_artist": "compilation, order_album_artist_name, order_album_name",
|
||||
"name": "order_album_name, order_album_artist_name",
|
||||
"artist": "compilation, order_album_artist_name, order_album_name",
|
||||
"album_artist": "compilation, order_album_artist_name, order_album_name",
|
||||
// TODO Rename this to just year (or date)
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name",
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
@@ -119,11 +121,17 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
|
||||
"has_rating": hasRatingFilter,
|
||||
"missing": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
"role_total_id": allRolesFilter,
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.AlbumLevelTags() {
|
||||
filters[string(tag)] = tagIDFilter
|
||||
}
|
||||
|
||||
for role := range model.AllRoles {
|
||||
filters["role_"+role+"_id"] = artistRoleFilter
|
||||
}
|
||||
|
||||
return filters
|
||||
})
|
||||
|
||||
@@ -153,20 +161,30 @@ func yearFilter(_ string, value interface{}) Sqlizer {
|
||||
}
|
||||
}
|
||||
|
||||
// BFR: Support other roles
|
||||
func artistFilter(_ string, value interface{}) Sqlizer {
|
||||
return Or{
|
||||
Exists("json_tree(Participants, '$.albumartist')", Eq{"value": value}),
|
||||
Exists("json_tree(Participants, '$.artist')", Eq{"value": value}),
|
||||
Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}),
|
||||
Exists("json_tree(participants, '$.artist')", Eq{"value": value}),
|
||||
}
|
||||
// For any role:
|
||||
//return Like{"Participants": fmt.Sprintf(`%%"%s"%%`, value)}
|
||||
}
|
||||
|
||||
func artistRoleFilter(name string, value interface{}) Sqlizer {
|
||||
roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id")
|
||||
|
||||
// Check if the role name is valid. If not, return an invalid filter
|
||||
if _, ok := model.AllRoles[roleName]; !ok {
|
||||
return Gt{"": nil}
|
||||
}
|
||||
return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value})
|
||||
}
|
||||
|
||||
func allRolesFilter(_ string, value interface{}) Sqlizer {
|
||||
return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)}
|
||||
}
|
||||
|
||||
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
sql := r.newSelect()
|
||||
sql = r.withAnnotation(sql, "album.id")
|
||||
// BFR WithParticipants (for filtering by name)?
|
||||
return r.count(sql, options...)
|
||||
}
|
||||
|
||||
@@ -297,7 +315,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error)
|
||||
// RefreshPlayCounts updates the play count and last play date annotations for all albums, based
|
||||
// on the media files associated with them.
|
||||
func (r *albumRepository) RefreshPlayCounts() (int64, error) {
|
||||
query := rawSQL(`
|
||||
query := Expr(`
|
||||
with play_counts as (
|
||||
select user_id, album_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
|
||||
from media_file
|
||||
|
||||
@@ -2,6 +2,7 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -236,6 +237,52 @@ var _ = Describe("AlbumRepository", func() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("artistRoleFilter", func() {
|
||||
DescribeTable("creates correct SQL expressions for artist roles",
|
||||
func(filterName, artistID, expectedSQL string) {
|
||||
sqlizer := artistRoleFilter(filterName, artistID)
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(expectedSQL))
|
||||
Expect(args).To(Equal([]interface{}{artistID}))
|
||||
},
|
||||
Entry("artist role", "role_artist_id", "123",
|
||||
"exists (select 1 from json_tree(participants, '$.artist') where value = ?)"),
|
||||
Entry("albumartist role", "role_albumartist_id", "456",
|
||||
"exists (select 1 from json_tree(participants, '$.albumartist') where value = ?)"),
|
||||
Entry("composer role", "role_composer_id", "789",
|
||||
"exists (select 1 from json_tree(participants, '$.composer') where value = ?)"),
|
||||
)
|
||||
|
||||
It("works with the actual filter map", func() {
|
||||
filters := albumFilters()
|
||||
|
||||
for roleName := range model.AllRoles {
|
||||
filterName := "role_" + roleName + "_id"
|
||||
filterFunc, exists := filters[filterName]
|
||||
Expect(exists).To(BeTrue(), fmt.Sprintf("Filter %s should exist", filterName))
|
||||
|
||||
sqlizer := filterFunc(filterName, "test-id")
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName)))
|
||||
Expect(args).To(Equal([]interface{}{"test-id"}))
|
||||
}
|
||||
})
|
||||
|
||||
It("rejects invalid roles", func() {
|
||||
sqlizer := artistRoleFilter("role_invalid_id", "123")
|
||||
_, _, err := sqlizer.ToSql()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects invalid filter names", func() {
|
||||
sqlizer := artistRoleFilter("invalid_name", "123")
|
||||
_, _, err := sqlizer.ToSql()
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func _p(id, name string, sortName ...string) model.Participant {
|
||||
|
||||
@@ -85,7 +85,7 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error {
|
||||
m["full_text"] = formatFullText(a.Name, a.SortArtistName)
|
||||
|
||||
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
|
||||
// BFR: Better way to handle this?
|
||||
// TODO: Better way to handle this?
|
||||
if v, ok := m["sort_artist_name"]; !ok || v.(string) == "" {
|
||||
delete(m, "sort_artist_name")
|
||||
}
|
||||
@@ -134,7 +134,6 @@ func roleFilter(_ string, role any) Sqlizer {
|
||||
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
|
||||
query := r.newSelect(options...).Columns("artist.*")
|
||||
query = r.withAnnotation(query, "artist.id")
|
||||
// BFR How to handle counts and sizes (per role)?
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -240,7 +239,7 @@ func (r *artistRepository) purgeEmpty() error {
|
||||
// RefreshPlayCounts updates the play count and last play date annotations for all artists, based
|
||||
// on the media files associated with them.
|
||||
func (r *artistRepository) RefreshPlayCounts() (int64, error) {
|
||||
query := rawSQL(`
|
||||
query := Expr(`
|
||||
with play_counts as (
|
||||
select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
|
||||
from media_file
|
||||
@@ -260,76 +259,123 @@ on conflict (user_id, item_id, item_type) do update
|
||||
return r.executeSQL(query)
|
||||
}
|
||||
|
||||
// RefreshStats updates the stats field for all artists, based on the media files associated with them.
|
||||
// BFR Maybe filter by "touched" artists?
|
||||
// RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time.
|
||||
// It processes artists in batches to handle potentially large updates.
|
||||
func (r *artistRepository) RefreshStats() (int64, error) {
|
||||
// First get all counters, one query groups by artist/role, and another with totals per artist.
|
||||
// Union both queries and group by artist to get a single row of counters per artist/role.
|
||||
// Then format the counters in a JSON object, one key for each role.
|
||||
// Finally update the artist table with the new counters
|
||||
// In all queries, atom is the artist ID and path is the role (or "total" for the totals)
|
||||
query := rawSQL(`
|
||||
-- CTE to get counters for each artist, grouped by role
|
||||
with artist_role_counters as (
|
||||
-- Get counters for each artist, grouped by role
|
||||
-- (remove the index from the role: composer[0] => composer
|
||||
select atom as artist_id,
|
||||
substr(
|
||||
replace(jt.path, '$.', ''),
|
||||
1,
|
||||
case when instr(replace(jt.path, '$.', ''), '[') > 0
|
||||
then instr(replace(jt.path, '$.', ''), '[') - 1
|
||||
else length(replace(jt.path, '$.', ''))
|
||||
end
|
||||
) as role,
|
||||
count(distinct album_id) as album_count,
|
||||
count(mf.id) as count,
|
||||
sum(size) as size
|
||||
from media_file mf
|
||||
left join json_tree(participants) jt
|
||||
where atom is not null and key = 'id'
|
||||
group by atom, role
|
||||
),
|
||||
touchedArtistsQuerySQL := `
|
||||
SELECT DISTINCT mfa.artist_id
|
||||
FROM media_file_artists mfa
|
||||
JOIN media_file mf ON mfa.media_file_id = mf.id
|
||||
WHERE mf.updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1)
|
||||
`
|
||||
|
||||
-- CTE to get the totals for each artist
|
||||
artist_total_counters as (
|
||||
select mfa.artist_id,
|
||||
'total' as role,
|
||||
count(distinct mf.album_id) as album_count,
|
||||
count(distinct mf.id) as count,
|
||||
sum(mf.size) as size
|
||||
from (select artist_id, media_file_id
|
||||
from main.media_file_artists) as mfa
|
||||
join main.media_file mf on mfa.media_file_id = mf.id
|
||||
group by mfa.artist_id
|
||||
),
|
||||
var allTouchedArtistIDs []string
|
||||
if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil {
|
||||
return 0, fmt.Errorf("fetching touched artist IDs: %w", err)
|
||||
}
|
||||
|
||||
-- CTE to combine role and total counters
|
||||
combined_counters as (
|
||||
select artist_id, role, album_count, count, size
|
||||
from artist_role_counters
|
||||
union
|
||||
select artist_id, role, album_count, count, size
|
||||
from artist_total_counters
|
||||
),
|
||||
if len(allTouchedArtistIDs) == 0 {
|
||||
log.Debug(r.ctx, "RefreshStats: No artists to update.")
|
||||
return 0, nil
|
||||
}
|
||||
log.Debug(r.ctx, "RefreshStats: Found artists to update.", "count", len(allTouchedArtistIDs))
|
||||
|
||||
-- CTE to format the counters in a JSON object
|
||||
artist_counters as (
|
||||
select artist_id as id,
|
||||
json_group_object(
|
||||
replace(role, '"', ''),
|
||||
json_object('a', album_count, 'm', count, 's', size)
|
||||
) as counters
|
||||
from combined_counters
|
||||
group by artist_id
|
||||
)
|
||||
// Template for the batch update with placeholder markers that we'll replace
|
||||
batchUpdateStatsSQL := `
|
||||
WITH artist_role_counters AS (
|
||||
SELECT jt.atom AS artist_id,
|
||||
substr(
|
||||
replace(jt.path, '$.', ''),
|
||||
1,
|
||||
CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0
|
||||
THEN instr(replace(jt.path, '$.', ''), '[') - 1
|
||||
ELSE length(replace(jt.path, '$.', ''))
|
||||
END
|
||||
) AS role,
|
||||
count(DISTINCT mf.album_id) AS album_count,
|
||||
count(mf.id) AS count,
|
||||
sum(mf.size) AS size
|
||||
FROM media_file mf
|
||||
JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
|
||||
WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
|
||||
GROUP BY jt.atom, role
|
||||
),
|
||||
artist_total_counters AS (
|
||||
SELECT mfa.artist_id,
|
||||
'total' AS role,
|
||||
count(DISTINCT mf.album_id) AS album_count,
|
||||
count(DISTINCT mf.id) AS count,
|
||||
sum(mf.size) AS size
|
||||
FROM media_file_artists mfa
|
||||
JOIN media_file mf ON mfa.media_file_id = mf.id
|
||||
WHERE mfa.artist_id IN (TOTAL_IDS_PLACEHOLDER) -- Will replace with actual placeholders
|
||||
GROUP BY mfa.artist_id
|
||||
),
|
||||
combined_counters AS (
|
||||
SELECT artist_id, role, album_count, count, size FROM artist_role_counters
|
||||
UNION
|
||||
SELECT artist_id, role, album_count, count, size FROM artist_total_counters
|
||||
),
|
||||
artist_counters AS (
|
||||
SELECT artist_id AS id,
|
||||
json_group_object(
|
||||
replace(role, '"', ''),
|
||||
json_object('a', album_count, 'm', count, 's', size)
|
||||
) AS counters
|
||||
FROM combined_counters
|
||||
GROUP BY artist_id
|
||||
)
|
||||
UPDATE artist
|
||||
SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'),
|
||||
updated_at = datetime(current_timestamp, 'localtime')
|
||||
WHERE artist.id IN (UPDATE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders
|
||||
|
||||
-- Update the artist table with the new counters
|
||||
update artist
|
||||
set stats = coalesce((select counters from artist_counters where artist_counters.id = artist.id), '{}'),
|
||||
updated_at = datetime(current_timestamp, 'localtime')
|
||||
where id <> ''; -- always true, to avoid warnings`)
|
||||
return r.executeSQL(query)
|
||||
var totalRowsAffected int64 = 0
|
||||
const batchSize = 1000
|
||||
|
||||
batchCounter := 0
|
||||
for artistIDBatch := range slice.CollectChunks(slices.Values(allTouchedArtistIDs), batchSize) {
|
||||
batchCounter++
|
||||
log.Trace(r.ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch))
|
||||
|
||||
// Create placeholders for each ID in the IN clauses
|
||||
placeholders := make([]string, len(artistIDBatch))
|
||||
for i := range artistIDBatch {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
// Don't add extra parentheses, the IN clause already expects them in SQL syntax
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
|
||||
// Replace the placeholder markers with actual SQL placeholders
|
||||
batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 1)
|
||||
batchSQL = strings.Replace(batchSQL, "TOTAL_IDS_PLACEHOLDER", inClause, 1)
|
||||
batchSQL = strings.Replace(batchSQL, "UPDATE_IDS_PLACEHOLDER", inClause, 1)
|
||||
|
||||
// Create a single parameter array with all IDs (repeated 3 times for each IN clause)
|
||||
// We need to repeat each ID 3 times (once for each IN clause)
|
||||
var args []interface{}
|
||||
for _, id := range artistIDBatch {
|
||||
args = append(args, id) // For ROLE_IDS_PLACEHOLDER
|
||||
}
|
||||
for _, id := range artistIDBatch {
|
||||
args = append(args, id) // For TOTAL_IDS_PLACEHOLDER
|
||||
}
|
||||
for _, id := range artistIDBatch {
|
||||
args = append(args, id) // For UPDATE_IDS_PLACEHOLDER
|
||||
}
|
||||
|
||||
// Now use Expr with the expanded SQL and all parameters
|
||||
sqlizer := Expr(batchSQL, args...)
|
||||
|
||||
rowsAffected, err := r.executeSQL(sqlizer)
|
||||
if err != nil {
|
||||
return totalRowsAffected, fmt.Errorf("executing batch update for artist stats (batch %d): %w", batchCounter, err)
|
||||
}
|
||||
totalRowsAffected += rowsAffected
|
||||
}
|
||||
|
||||
log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected)
|
||||
return totalRowsAffected, nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) {
|
||||
|
||||
@@ -57,14 +57,6 @@ func toCamelCase(str string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// rawSQL is a string that will be used as is in the SQL query executor
|
||||
// It does not support arguments
|
||||
type rawSQL string
|
||||
|
||||
func (r rawSQL) ToSql() (string, []interface{}, error) {
|
||||
return string(r), nil, nil
|
||||
}
|
||||
|
||||
func Exists(subTable string, cond squirrel.Sqlizer) existsCond {
|
||||
return existsCond{subTable: subTable, cond: cond, not: false}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func (r *libraryRepository) ScanEnd(id int) error {
|
||||
return err
|
||||
}
|
||||
// https://www.sqlite.org/pragma.html#pragma_optimize
|
||||
_, err = r.executeSQL(rawSQL("PRAGMA optimize=0x10012;"))
|
||||
_, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;"))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album": "order_album_name, release_date, disc_number, track_number, order_artist_name, title",
|
||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
@@ -87,11 +87,12 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
|
||||
|
||||
var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
filters := map[string]filterFunc{
|
||||
"id": idFilter("media_file"),
|
||||
"title": fullTextFilter("media_file"),
|
||||
"starred": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"id": idFilter("media_file"),
|
||||
"title": fullTextFilter("media_file"),
|
||||
"starred": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"artists_id": artistFilter,
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.TagMappings() {
|
||||
@@ -105,7 +106,6 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
query := r.newSelect()
|
||||
query = r.withAnnotation(query, "media_file.id")
|
||||
// BFR WithParticipants (for filtering by name)?
|
||||
return r.count(query, options...)
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func (r *mediaFileRepository) MarkMissingByFolder(missing bool, folderIDs ...str
|
||||
|
||||
// GetMissingAndMatching returns all mediafiles that are missing and their potential matches (comparing PIDs)
|
||||
// that were added/updated after the last scan started. The result is ordered by PID.
|
||||
// It does not need to load bookmarks, annotations and participnts, as they are not used by the scanner.
|
||||
// It does not need to load bookmarks, annotations and participants, as they are not used by the scanner.
|
||||
func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
|
||||
subQ := r.newSelect().Columns("pid").
|
||||
Where(And{
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
@@ -53,17 +52,6 @@ var _ = Describe("MediaRepository", func() {
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
XIt("filters by genre", func() {
|
||||
Expect(mr.GetAll(model.QueryOptions{
|
||||
Sort: "genre.name asc, title asc",
|
||||
Filters: squirrel.Eq{"genre.name": "Rock"},
|
||||
})).To(Equal(model.MediaFiles{
|
||||
songDayInALife,
|
||||
songAntenna,
|
||||
songComeTogether,
|
||||
}))
|
||||
})
|
||||
|
||||
Context("Annotations", func() {
|
||||
It("increments play count when the tracks does not have annotations", func() {
|
||||
id := "incplay.firsttime"
|
||||
|
||||
@@ -29,13 +29,6 @@ func TestPersistence(t *testing.T) {
|
||||
RunSpecs(t, "Persistence Suite")
|
||||
}
|
||||
|
||||
// BFR Test tags
|
||||
//var (
|
||||
// genreElectronic = model.Genre{ID: "gn-1", Name: "Electronic"}
|
||||
// genreRock = model.Genre{ID: "gn-2", Name: "Rock"}
|
||||
// testGenres = model.Genres{genreElectronic, genreRock}
|
||||
//)
|
||||
|
||||
func mf(mf model.MediaFile) model.MediaFile {
|
||||
mf.Tags = model.Tags{}
|
||||
mf.LibraryID = 1
|
||||
|
||||
@@ -145,7 +145,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
// BFR Validate these tests
|
||||
// TODO Validate these tests
|
||||
XContext("child smart playlists", func() {
|
||||
When("refresh day has expired", func() {
|
||||
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
|
||||
|
||||
@@ -51,11 +51,16 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
})
|
||||
p.setSortMappings(
|
||||
map[string]string{
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name",
|
||||
"album": "order_album_name, order_album_artist_name",
|
||||
"title": "order_title",
|
||||
"duration": "duration", // To make sure the field will be whitelisted
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name",
|
||||
"album_artist": "order_album_artist_name",
|
||||
"album": "order_album_name, order_album_artist_name",
|
||||
"title": "order_title",
|
||||
// To make sure these fields will be whitelisted
|
||||
"duration": "duration",
|
||||
"year": "year",
|
||||
"bpm": "bpm",
|
||||
"channels": "channels",
|
||||
},
|
||||
"f") // TODO I don't like this solution, but I won't change it now as it's not the focus of BFR.
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ where tag.id = updated_values.id;
|
||||
`
|
||||
for _, table := range []string{"album", "media_file"} {
|
||||
start := time.Now()
|
||||
query := rawSQL(fmt.Sprintf(template, table))
|
||||
query := Expr(fmt.Sprintf(template, table))
|
||||
c, err := r.executeSQL(query)
|
||||
log.Debug(r.ctx, "Updated tag counts", "table", table, "elapsed", time.Since(start), "updated", c)
|
||||
if err != nil {
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
|
||||
<Component Id="Configuration" Guid="9e17ed4b-ef13-44bf-a605-ed4132cff7f6" Win64="$(var.Win64)">
|
||||
<IniFile Id="ConfigurationPort" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="Port" Section="default" Value="'[ND_PORT]'" />
|
||||
<IniFile Id="ConfigurationMusicDir" Name="navidrome.ini" Action="addLine" Directory="INSTALLDIR" Key="MusicFolder" Section="default" Value="'[ND_MUSICFOLDER]'" />
|
||||
<IniFile Id="ConfigurationDataDir" Name="navidrome.ini" Action="addLine" Directory="INSTALLDIR" Key="DataFolder" Section="default" Value="'[ND_DATAFOLDER]'" />
|
||||
<IniFile Id="FFmpegPath" Name="navidrome.ini" Action="addLine" Directory="INSTALLDIR" Key="FFmpegPath" Section="default" Value="'[INSTALLDIR]ffmpeg.exe'" />
|
||||
<IniFile Id="ConfigurationMusicDir" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="MusicFolder" Section="default" Value="'[ND_MUSICFOLDER]'" />
|
||||
<IniFile Id="ConfigurationDataDir" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="DataFolder" Section="default" Value="'[ND_DATAFOLDER]'" />
|
||||
<IniFile Id="FFmpegPath" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="FFmpegPath" Section="default" Value="'[INSTALLDIR]ffmpeg.exe'" />
|
||||
</Component>
|
||||
|
||||
<Component Id='MainExecutable' Guid='e645aa06-8bbc-40d6-8d3c-73b4f5b76fd7' Win64="$(var.Win64)">
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 68 KiB |
@@ -18,6 +18,9 @@
|
||||
"size": "Mida del fitxer",
|
||||
"updatedAt": "Actualitzat",
|
||||
"bitRate": "Taxa de bits",
|
||||
"bitDepth": "Bits",
|
||||
"sampleRate": "Freqüencia de mostreig",
|
||||
"channels": "Canals",
|
||||
"discSubtitle": "Subtítol del disc",
|
||||
"starred": "Preferit",
|
||||
"comment": "Comentari",
|
||||
@@ -25,8 +28,13 @@
|
||||
"quality": "Qualitat",
|
||||
"bpm": "tempo",
|
||||
"playDate": "Darrer resproduït",
|
||||
"channels": "Canals",
|
||||
"createdAt": ""
|
||||
"createdAt": "Creat el",
|
||||
"grouping": "Agrupació",
|
||||
"mood": "Sentiment",
|
||||
"participants": "Participants",
|
||||
"tags": "Etiquetes",
|
||||
"mappedTags": "Etiquetes assignades",
|
||||
"rawTags": "Etiquetes sense processar"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Reprodueix després",
|
||||
@@ -46,6 +54,7 @@
|
||||
"duration": "Durada",
|
||||
"songCount": "Cançons",
|
||||
"playCount": "Reproduccions",
|
||||
"size": "Mida",
|
||||
"name": "Nom",
|
||||
"genre": "Gènere",
|
||||
"compilation": "Compilació",
|
||||
@@ -53,22 +62,28 @@
|
||||
"updatedAt": "Actualitzat ",
|
||||
"comment": "Comentari",
|
||||
"rating": "Valoració",
|
||||
"createdAt": "",
|
||||
"size": "",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": ""
|
||||
"createdAt": "Creat el",
|
||||
"size": "Mida",
|
||||
"originalDate": "Original",
|
||||
"releaseDate": "Publicat",
|
||||
"releases": "LLançament |||| Llançaments",
|
||||
"released": "Publicat",
|
||||
"recordLabel": "Discogràfica",
|
||||
"catalogNum": "Número de catàleg",
|
||||
"releaseType": "Tipus de publicació",
|
||||
"grouping": "Agrupació",
|
||||
"media": "Mitjà",
|
||||
"mood": "Sentiment"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Reprodueix",
|
||||
"playNext": "Reprodueix la següent",
|
||||
"addToQueue": "Reprodueix després",
|
||||
"share": "Compartir",
|
||||
"shuffle": "Aleatori",
|
||||
"addToPlaylist": "Afegeix a la llista",
|
||||
"download": "Descarrega",
|
||||
"info": "Obtén informació",
|
||||
"share": ""
|
||||
"info": "Obtén informació"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Tot",
|
||||
@@ -85,11 +100,27 @@
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"albumCount": "Nombre d'àlbums",
|
||||
"songCount": "Compte de cançons",
|
||||
"songCount": "Nombre de cançons",
|
||||
"size": "Mida",
|
||||
"playCount": "Reproduccions",
|
||||
"rating": "Valoració",
|
||||
"genre": "Gènere",
|
||||
"size": ""
|
||||
"role": "Rol"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum",
|
||||
"artist": "Artista |||| Artistes",
|
||||
"composer": "Compositor |||| Compositors",
|
||||
"conductor": "Conductor |||| Conductors",
|
||||
"lyricist": "Lletrista |||| Lletristes",
|
||||
"arranger": "Arranjador |||| Arranjadors",
|
||||
"producer": "Productor |||| Productors",
|
||||
"director": "Director |||| Directors",
|
||||
"engineer": "Enginyer |||| Enginyers",
|
||||
"mixer": "Mesclador |||| Mescladors",
|
||||
"remixer": "Remesclador |||| Remescladors",
|
||||
"djmixer": "DJ Mesclador |||| DJ Mescladors",
|
||||
"performer": "Intèrpret |||| Intèrprets"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -98,6 +129,7 @@
|
||||
"userName": "Nom d'usuari",
|
||||
"isAdmin": "És admin",
|
||||
"lastLoginAt": "Última connexió",
|
||||
"lastAccessAt": "Últim Accés",
|
||||
"updatedAt": "Actualitzat",
|
||||
"name": "Nom",
|
||||
"password": "Contrasenya",
|
||||
@@ -169,36 +201,53 @@
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "",
|
||||
"name": "Ràdio |||| Ràdios",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"streamUrl": "",
|
||||
"homePageUrl": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": ""
|
||||
"name": "Nom",
|
||||
"streamUrl": "URL del flux",
|
||||
"homePageUrl": "URL principal",
|
||||
"updatedAt": "Actualitzat",
|
||||
"createdAt": "Creat"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": ""
|
||||
"playNow": "Reprodueix"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "",
|
||||
"name": "Compartir |||| Compartits",
|
||||
"fields": {
|
||||
"username": "",
|
||||
"url": "",
|
||||
"description": "",
|
||||
"contents": "",
|
||||
"expiresAt": "",
|
||||
"lastVisitedAt": "",
|
||||
"visitCount": "",
|
||||
"format": "",
|
||||
"maxBitRate": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"downloadable": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"username": "Compartit per",
|
||||
"url": "URL",
|
||||
"description": "Descripció",
|
||||
"downloadable": "Permet descarregar?",
|
||||
"contents": "Continguts",
|
||||
"expiresAt": "Caduca",
|
||||
"lastVisitedAt": "Última Visita",
|
||||
"visitCount": "Visites",
|
||||
"format": "Format",
|
||||
"maxBitRate": "Taxa de bits màx.",
|
||||
"updatedAt": "Actualitzat",
|
||||
"createdAt": "Creat"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Fitxer faltant |||| Fitxers Faltants",
|
||||
"empty": "No falten fitxers",
|
||||
"fields": {
|
||||
"path": "Directori",
|
||||
"size": "Mida",
|
||||
"updatedAt": "Desaparegut"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Eliminar"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Fitxers faltants eliminats"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Gràcies d'haver instal·lat Navidrome!",
|
||||
@@ -211,28 +260,30 @@
|
||||
"password": "Contrasenya",
|
||||
"sign_in": "Inicia sessió",
|
||||
"sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar",
|
||||
"logout": "Sortida"
|
||||
"logout": "Sortida",
|
||||
"insightsCollectionNote": "Navidrome recull dades d'us anonimitzades per\najudar a millorar el projecte. Clica [aquí] per a saber-ne\nmés i no participar-hi si no vols"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Si us plau, useu solament lletres i nombres",
|
||||
"invalidChars": "Si us plau, useu només lletres i nombres",
|
||||
"passwordDoesNotMatch": "Les contrasenyes no coincideixen",
|
||||
"required": "Obligatori",
|
||||
"minLength": "Ha de tenir, si més no, %{min} caràcters",
|
||||
"maxLength": "Ha de tenir %{max} caràcter o menys",
|
||||
"minValue": "Ha de ser si més no %{min}",
|
||||
"maxLength": "Ha de tenir %{max} caràcters o menys",
|
||||
"minValue": "Ha de ser com a mínim %{min}",
|
||||
"maxValue": "Ha de ser %{max} o menys",
|
||||
"number": "Ha de ser un nombre",
|
||||
"email": "Ha de ser un correu vàlid",
|
||||
"oneOf": "Ha de ser un de: %{options}",
|
||||
"regex": "Ha de tenir el format (regexp): %{pattern}",
|
||||
"unique": "Ha de ser únic",
|
||||
"url": ""
|
||||
"url": "Ha de ser una URL vàlida"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Afegeix un filtre",
|
||||
"add": "Afegeix",
|
||||
"back": "Enrere",
|
||||
"bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Cancel·la",
|
||||
"clear_input_value": "Neteja el valor",
|
||||
"clone": "Clona",
|
||||
@@ -256,9 +307,8 @@
|
||||
"close_menu": "Tanca el menú",
|
||||
"unselect": "Anul·la la selecció",
|
||||
"skip": "Omet",
|
||||
"bulk_actions_mobile": "",
|
||||
"share": "",
|
||||
"download": ""
|
||||
"share": "Compartir",
|
||||
"download": "Descarregar"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Sí",
|
||||
@@ -334,7 +384,7 @@
|
||||
"i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat",
|
||||
"canceled": "Acció cancel·lada",
|
||||
"logged_out": "La sessió ha acabat, si us plau reconnecteu",
|
||||
"new_version": "Hi ha una versió nova disponible! Si us plau refresqueu aquesta finestra."
|
||||
"new_version": "Hi ha una versió nova disponible! Si us plau actualitzeu aquesta finestra."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Columnes a mostrar",
|
||||
@@ -351,29 +401,31 @@
|
||||
"noPlaylistsAvailable": "No n'hi ha cap disponible",
|
||||
"delete_user_title": "Esborra usuari '%{nom}'",
|
||||
"delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)",
|
||||
"remove_missing_title": "Eliminar fitxers faltants",
|
||||
"remove_missing_content": "Segur que vols eliminar els fitxers faltants seleccionats de la base de dades? Això eliminarà permanentment les referències a ells, incloent-hi el nombre de reproduccions i les valoracions.",
|
||||
"notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador",
|
||||
"notifications_not_available": "El navegador no suporta les notificacions o no heu connectat a Navidrome per https",
|
||||
"lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment",
|
||||
"lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm",
|
||||
"lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment",
|
||||
"lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm",
|
||||
"listenBrainzLinkSuccess": "Connectat correctament a ListenBrainz i seguiment activat com a: %{user}",
|
||||
"listenBrainzLinkFailure": "No s'ha pogut connectar a ListenBrainz: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz desconnectat i seguiment desactivat",
|
||||
"listenBrainzUnlinkFailure": "No s'ha pogut desconnectar de ListenBrainz",
|
||||
"openIn": {
|
||||
"lastfm": "Obri en Last.fm",
|
||||
"musicbrainz": "Obri en MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Llegeix més...",
|
||||
"listenBrainzLinkSuccess": "Ha reexit la vinculació amb ListenBrainz i se n'ha activat el seguiment com a usuari: %{user}",
|
||||
"listenBrainzLinkFailure": "No ha estat possible vincular-se a ListenBrainz: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "Desvinculat de ListenBrainz i desactivat el seguiment",
|
||||
"listenBrainzUnlinkFailure": "No s'ha pogut desvincular de ListenBrainz",
|
||||
"downloadOriginalFormat": "",
|
||||
"shareOriginalFormat": "",
|
||||
"shareDialogTitle": "",
|
||||
"shareBatchDialogTitle": "",
|
||||
"shareSuccess": "",
|
||||
"shareFailure": "",
|
||||
"downloadDialogTitle": "",
|
||||
"shareCopyToClipboard": ""
|
||||
"shareOriginalFormat": "Compartir en format original",
|
||||
"shareDialogTitle": "Compartir %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Copiar al porta-retalls: Ctrl+C, Enter",
|
||||
"shareSuccess": "URL copiada al porta-retalls: %{url}",
|
||||
"shareFailure": "Error copiant URL %{url} al porta-retalls",
|
||||
"downloadDialogTitle": "Deascarregar %{resource} '%{name}' (%{size})",
|
||||
"downloadOriginalFormat": "Descarregar en format original"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Discoteca",
|
||||
@@ -387,14 +439,15 @@
|
||||
"language": "Llengua",
|
||||
"defaultView": "Vista per defecte",
|
||||
"desktop_notifications": "Notificacions d'escriptori",
|
||||
"lastfmNotConfigured": "No s'ha configurat l'API de Last.fm",
|
||||
"lastfmScrobbling": "Activa el seguiment de Last.fm",
|
||||
"listenBrainzScrobbling": "Activa el seguiment de ListenBrainz",
|
||||
"replaygain": "",
|
||||
"preAmp": "",
|
||||
"replaygain": "Mode ReplayGain",
|
||||
"preAmp": "PreAmp de ReplayGain (dB)",
|
||||
"gain": {
|
||||
"none": "",
|
||||
"album": "",
|
||||
"track": ""
|
||||
"none": "Cap",
|
||||
"album": "Guany de l'àlbum",
|
||||
"track": "Guany de la pista"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -432,7 +485,12 @@
|
||||
"links": {
|
||||
"homepage": "Inici",
|
||||
"source": "Codi font",
|
||||
"featureRequests": "Sol·licitud de funcionalitats"
|
||||
"featureRequests": "Sol·licitud de funcionalitats",
|
||||
"lastInsightsCollection": "Última recolecció d'informació",
|
||||
"insights": {
|
||||
"disabled": "Desactivada",
|
||||
"waiting": "Esperant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -454,7 +512,7 @@
|
||||
"vol_up": "Apuja el volum",
|
||||
"vol_down": "Abaixa el volum",
|
||||
"toggle_love": "Afegeix la pista a favorits",
|
||||
"current_song": ""
|
||||
"current_song": "Anar a la cançó actual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
515
resources/i18n/el.json
Normal file
515
resources/i18n/el.json
Normal file
@@ -0,0 +1,515 @@
|
||||
{
|
||||
"languageName": "Ελληνικά",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Τραγούδι |||| Τραγούδια",
|
||||
"fields": {
|
||||
"albumArtist": "Καλλιτεχνης Αλμπουμ",
|
||||
"duration": "Διαρκεια",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Αναπαραγωγες",
|
||||
"title": "Τιτλος",
|
||||
"artist": "Καλλιτεχνης",
|
||||
"album": "Αλμπουμ",
|
||||
"path": "Διαδρομη αρχειου",
|
||||
"genre": "Ειδος",
|
||||
"compilation": "Συλλογή",
|
||||
"year": "Ετος",
|
||||
"size": "Μεγεθος αρχειου",
|
||||
"updatedAt": "Ενημερωθηκε",
|
||||
"bitRate": "Ρυθμός Bit",
|
||||
"discSubtitle": "Υπότιτλοι Δίσκου",
|
||||
"starred": "Αγαπημένο",
|
||||
"comment": "Σχόλιο",
|
||||
"rating": "Βαθμολογια",
|
||||
"quality": "Ποιοτητα",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Παίχτηκε Τελευταία",
|
||||
"channels": "Κανάλια",
|
||||
"createdAt": "Ημερομηνία προσθήκης",
|
||||
"grouping": "Ομαδοποίηση",
|
||||
"mood": "Διάθεση",
|
||||
"participants": "Πρόσθετοι συμμετέχοντες",
|
||||
"tags": "Πρόσθετες Ετικέτες",
|
||||
"mappedTags": "Χαρτογραφημένες ετικέτες",
|
||||
"rawTags": "Ακατέργαστες ετικέτες",
|
||||
"bitDepth": "Λίγο βάθος"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Αναπαραγωγη Μετα",
|
||||
"playNow": "Αναπαραγωγή Τώρα",
|
||||
"addToPlaylist": "Προσθήκη στη λίστα αναπαραγωγής",
|
||||
"shuffleAll": "Ανακατεμα ολων",
|
||||
"download": "Ληψη",
|
||||
"playNext": "Επόμενη Αναπαραγωγή",
|
||||
"info": "Εμφάνιση Πληροφοριών"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Άλμπουμ |||| Άλμπουμ",
|
||||
"fields": {
|
||||
"albumArtist": "Καλλιτεχνης Αλμπουμ",
|
||||
"artist": "Καλλιτεχνης",
|
||||
"duration": "Διαρκεια",
|
||||
"songCount": "Τραγουδια",
|
||||
"playCount": "Αναπαραγωγες",
|
||||
"name": "Ονομα",
|
||||
"genre": "Ειδος",
|
||||
"compilation": "Συλλογη",
|
||||
"year": "Ετος",
|
||||
"updatedAt": "Ενημερωθηκε",
|
||||
"comment": "Σχόλιο",
|
||||
"rating": "Βαθμολογια",
|
||||
"createdAt": "Ημερομηνία προσθήκης",
|
||||
"size": "Μέγεθος",
|
||||
"originalDate": "Πρωτότυπο",
|
||||
"releaseDate": "Κυκλοφόρησε",
|
||||
"releases": "Έκδοση |||| Εκδόσεις",
|
||||
"released": "Κυκλοφόρησε",
|
||||
"recordLabel": "Επιγραφή",
|
||||
"catalogNum": "Αριθμός καταλόγου",
|
||||
"releaseType": "Τύπος",
|
||||
"grouping": "Ομαδοποίηση",
|
||||
"media": "Μέσα",
|
||||
"mood": "Διάθεση",
|
||||
"date": "Ημερομηνία Ηχογράφησης"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Αναπαραγωγή",
|
||||
"playNext": "Αναπαραγωγη Μετα",
|
||||
"addToQueue": "Αναπαραγωγη Αργοτερα",
|
||||
"shuffle": "Ανακατεμα",
|
||||
"addToPlaylist": "Προσθηκη στη λιστα αναπαραγωγης",
|
||||
"download": "Ληψη",
|
||||
"info": "Εμφάνιση Πληροφοριών",
|
||||
"share": "Μερίδιο"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Όλα",
|
||||
"random": "Τυχαία",
|
||||
"recentlyAdded": "Νέες Προσθήκες",
|
||||
"recentlyPlayed": "Παίχτηκαν Πρόσφατα",
|
||||
"mostPlayed": "Παίζονται Συχνά",
|
||||
"starred": "Αγαπημένα",
|
||||
"topRated": "Κορυφαία"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Καλλιτέχνης |||| Καλλιτέχνες",
|
||||
"fields": {
|
||||
"name": "Ονομα",
|
||||
"albumCount": "Αναπαραγωγές Αλμπουμ",
|
||||
"songCount": "Αναπαραγωγες Τραγουδιου",
|
||||
"playCount": "Αναπαραγωγες",
|
||||
"rating": "Βαθμολογια",
|
||||
"genre": "Είδος",
|
||||
"size": "Μέγεθος",
|
||||
"role": "Ρόλος"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Καλλιτέχνης Άλμπουμ |||| Καλλιτέχνες άλμπουμ",
|
||||
"artist": "Καλλιτέχνης |||| Καλλιτέχνες",
|
||||
"composer": "Συνθέτης |||| Συνθέτες",
|
||||
"conductor": "Μαέστρος |||| Μαέστροι",
|
||||
"lyricist": "Στιχουργός |||| Στιχουργοί",
|
||||
"arranger": "Τακτοποιητής |||| Τακτοποιητές",
|
||||
"producer": "Παραγωγός |||| Παραγωγοί",
|
||||
"director": "Διευθυντής |||| Διευθυντές",
|
||||
"engineer": "Μηχανικός |||| Μηχανικοί",
|
||||
"mixer": "Μίξερ |||| Μίξερ",
|
||||
"remixer": "Ρεμίξερ |||| Ρεμίξερ",
|
||||
"djmixer": "Dj Μίξερ |||| Dj Μίξερ",
|
||||
"performer": "Εκτελεστής |||| Ερμηνευτές"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Χρήστης |||| Χρήστες",
|
||||
"fields": {
|
||||
"userName": "Ονομα Χρηστη",
|
||||
"isAdmin": "Ειναι Διαχειριστης",
|
||||
"lastLoginAt": "Τελευταια συνδεση στις",
|
||||
"updatedAt": "Ενημερωθηκε",
|
||||
"name": "Όνομα",
|
||||
"password": "Κωδικός Πρόσβασης",
|
||||
"createdAt": "Δημιουργήθηκε στις",
|
||||
"changePassword": "Αλλαγή Κωδικού Πρόσβασης;",
|
||||
"currentPassword": "Υπάρχων Κωδικός Πρόσβασης",
|
||||
"newPassword": "Νέος Κωδικός Πρόσβασης",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Τελευταία Πρόσβαση"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Ο χρήστης δημιουργήθηκε",
|
||||
"updated": "Ο χρήστης ενημερώθηκε",
|
||||
"deleted": "Ο χρήστης διαγράφηκε"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Εισάγετε το token του χρήστη σας στο ListenBrainz.",
|
||||
"clickHereForToken": "Κάντε κλικ εδώ για να αποκτήσετε το token σας"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Συσκευή Αναπαραγωγής |||| Συσκευές Αναπαραγωγής",
|
||||
"fields": {
|
||||
"name": "Όνομα",
|
||||
"transcodingId": "Διακωδικοποίηση",
|
||||
"maxBitRate": "Μεγ. Ρυθμός Bit",
|
||||
"client": "Πελάτης",
|
||||
"userName": "Ονομα Χρηστη",
|
||||
"lastSeen": "Τελευταια προβολη στις",
|
||||
"reportRealPath": "Αναφορά Πραγματικής Διαδρομής",
|
||||
"scrobbleEnabled": "Αποστολή Scrobbles σε εξωτερικές συσκευές"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Διακωδικοποίηση |||| Διακωδικοποιήσεις",
|
||||
"fields": {
|
||||
"name": "Όνομα",
|
||||
"targetFormat": "Μορφη Προορισμου",
|
||||
"defaultBitRate": "Προκαθορισμένος Ρυθμός Bit",
|
||||
"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": "Περιγραφή",
|
||||
"contents": "Περιεχόμενα",
|
||||
"expiresAt": "Λήγει",
|
||||
"lastVisitedAt": "Τελευταία Επίσκεψη",
|
||||
"visitCount": "Επισκέψεις",
|
||||
"format": "Μορφή",
|
||||
"maxBitRate": "Μέγ. Ρυθμός Bit",
|
||||
"updatedAt": "Ενημερώθηκε στις",
|
||||
"createdAt": "Δημιουργήθηκε στις",
|
||||
"downloadable": "Επιτρέπονται οι λήψεις?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Λείπει αρχείο |||| Λείπουν αρχεία",
|
||||
"fields": {
|
||||
"path": "Διαδρομή",
|
||||
"size": "Μέγεθος",
|
||||
"updatedAt": "Εξαφανίστηκε"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Αφαίρεση"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Λείπει αρχείο(α) αφαιρέθηκε"
|
||||
},
|
||||
"empty": "Δεν λείπουν αρχεία"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Σας ευχαριστούμε που εγκαταστήσατε το Navidrome!",
|
||||
"welcome2": "Για να ξεκινήσετε, δημιουργήστε έναν χρήστη ως διαχειριστή",
|
||||
"confirmPassword": "Επιβεβαίωση κωδικού πρόσβασης",
|
||||
"buttonCreateAdmin": "Δημιουργία Διαχειριστή",
|
||||
"auth_check_error": "Παρακαλούμε συνδεθείτε για να συννεχίσετε",
|
||||
"user_menu": "Προφίλ",
|
||||
"username": "Ονομα Χρηστη",
|
||||
"password": "Κωδικός Πρόσβασης",
|
||||
"sign_in": "Σύνδεση",
|
||||
"sign_in_error": "Η αυθεντικοποίηση απέτυχε, παρακαλούμε προσπαθήστε ξανά",
|
||||
"logout": "Αποσύνδεση",
|
||||
"insightsCollectionNote": "Το Navidrome συλλέγει ανώνυμα δεδομένα χρήσης σε\nβοηθήσουν στη βελτίωση του έργου. Κάντε κλικ [εδώ] για να μάθετε\nπερισσότερα και να εξαιρεθείτε αν θέλετε"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Παρακαλούμε χρησημοποιήστε μόνο γράμματα και αριθμούς",
|
||||
"passwordDoesNotMatch": "Ο κωδικός πρόσβασης δεν ταιριάζει",
|
||||
"required": "Υποχρεωτικό",
|
||||
"minLength": "Πρέπει να είναι %{min} χαρακτήρες τουλάχιστον",
|
||||
"maxLength": "Πρέπει να είναι %{max} χαρακτήρες ή λιγότερο",
|
||||
"minValue": "Πρέπει να είναι τουλάχιστον %{min}",
|
||||
"maxValue": "Πρέπει να είναι %{max} ή λιγότερο",
|
||||
"number": "Πρέπει να είναι αριθμός",
|
||||
"email": "Πρέπει να είναι ένα έγκυρο email",
|
||||
"oneOf": "Πρέπει να είναι ένα από τα ακόλουθα: %{options}",
|
||||
"regex": "Πρέπει να ταιριάζει με ένα συγκεκριμένο τύπο (κανονική έκφραση): %{pattern}",
|
||||
"unique": "Πρέπει να είναι μοναδικό",
|
||||
"url": "Πρέπει να είναι έγκυρη διεύθυνση URL"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Προσθηκη φιλτρου",
|
||||
"add": "Προσθήκη",
|
||||
"back": "Πίσω",
|
||||
"bulk_actions": "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": "Παράβλεψη",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"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": "Σφάλμα παρόχου δεδομένων. Παρακαλούμε συμβουλευτείτε την κονσόλα για περισσότερες πληροφορίες.",
|
||||
"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 έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling ενεργοποιήθηκε",
|
||||
"lastfmLinkFailure": "Δεν μπορεί να πραγματοποιηθεί διασύνδεση με το Last.fm",
|
||||
"lastfmUnlinkSuccess": "Το Last.fm αποσυνδέθηκε και η λειτουργία scrobbling έχει απενεργοποιηθεί",
|
||||
"lastfmUnlinkFailure": "Το Last.fm δεν μπορεί να αποσυνδεθεί",
|
||||
"openIn": {
|
||||
"lastfm": "Άνοιγμα στο Last.fm",
|
||||
"musicbrainz": "Άνοιγμα στο MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Διαβάστε περισσότερα...",
|
||||
"listenBrainzLinkSuccess": "Το ListenBrainz έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling έχει ενεργοποιηθεί για το χρήστη: %{user}",
|
||||
"listenBrainzLinkFailure": "Το ListenBrainz δεν μπορεί να διασυνδεθεί: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "Το ListenBrainz έχει αποσυνδεθεί και το scrobbling έχει απενεργοποιηθεί",
|
||||
"listenBrainzUnlinkFailure": "Το ListenBrainz δεν μπορεί να διασυνδεθεί",
|
||||
"downloadOriginalFormat": "Λήψη σε αρχική μορφή",
|
||||
"shareOriginalFormat": "Κοινή χρήση σε αρχική μορφή",
|
||||
"shareDialogTitle": "Κοινή χρήση %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Κοινή χρήση 1 %{resource} |||| Κοινή χρήση %{smart_count} %{resource}",
|
||||
"shareSuccess": "Το URL αντιγράφτηκε στο πρόχειρο: %{url}",
|
||||
"shareFailure": "Σφάλμα κατά την αντιγραφή της διεύθυνσης URL %{url} στο πρόχειρο",
|
||||
"downloadDialogTitle": "Λήψη %{resource} '%{name}'(%{size})",
|
||||
"shareCopyToClipboard": "Αντιγραφή στο πρόχειρο: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Αφαιρέστε τα αρχεία που λείπουν",
|
||||
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων; Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Βιβλιοθήκη",
|
||||
"settings": "Ρυθμίσεις",
|
||||
"version": "Έκδοση",
|
||||
"theme": "Θέμα",
|
||||
"personal": {
|
||||
"name": "Προσωπικές",
|
||||
"options": {
|
||||
"theme": "Θέμα",
|
||||
"language": "Γλώσσα",
|
||||
"defaultView": "Προκαθορισμένη προβολή",
|
||||
"desktop_notifications": "Ειδοποιήσεις στην Επιφάνεια Εργασίας",
|
||||
"lastfmScrobbling": "Λειτουργία Scrobble στο Last.fm",
|
||||
"listenBrainzScrobbling": "Λειτουργία Scrobble στο ListenBrainz",
|
||||
"replaygain": "Λειτουργία ReplayGain",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"gain": {
|
||||
"none": "Ανενεργό",
|
||||
"album": "Χρησιμοποιήστε το Album Gain",
|
||||
"track": "Χρησιμοποιήστε το Track Gain"
|
||||
},
|
||||
"lastfmNotConfigured": "Το Last.fm API-Key δεν έχει ρυθμιστεί"
|
||||
}
|
||||
},
|
||||
"albumList": "Άλμπουμ",
|
||||
"about": "Σχετικά",
|
||||
"playlists": "Λίστες Αναπαραγωγής",
|
||||
"sharedPlaylists": "Κοινοποιημένες Λίστες Αναπαραγωγής"
|
||||
},
|
||||
"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": "Αιτήματα χαρακτηριστικών",
|
||||
"lastInsightsCollection": "Τελευταία συλλογή πληροφοριών",
|
||||
"insights": {
|
||||
"disabled": "Απενεργοποιημένο",
|
||||
"waiting": "Αναμονή"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Δραστηριότητα",
|
||||
"totalScanned": "Σαρώμένοι Φάκελοι",
|
||||
"quickScan": "Γρήγορη Σάρωση",
|
||||
"fullScan": "Πλήρης Σάρωση",
|
||||
"serverUptime": "Λειτουργία Διακομιστή",
|
||||
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ"
|
||||
},
|
||||
"help": {
|
||||
"title": "Συντομεύσεις του Navidrome",
|
||||
"hotkeys": {
|
||||
"show_help": "Προβολή αυτής της Βοήθειας",
|
||||
"toggle_menu": "Εναλλαγή Μπάρας Μενού",
|
||||
"toggle_play": "Αναπαραγωγή / Παύση",
|
||||
"prev_song": "Προηγούμενο Τραγούδι",
|
||||
"next_song": "Επόμενο Τραγούδι",
|
||||
"vol_up": "Αύξηση Έντασης",
|
||||
"vol_down": "Μείωση Έντασης",
|
||||
"toggle_love": "Προσθήκη αυτού του κομματιού στα αγαπημένα",
|
||||
"current_song": "Μεταβείτε στο Τρέχον τραγούδι"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,460 +1,515 @@
|
||||
{
|
||||
"languageName": "Esperanto",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "kanto |||| kantoj",
|
||||
"fields": {
|
||||
"albumArtist": "Albumo artista",
|
||||
"duration": "Tempo",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Nombro de ŝkotoj",
|
||||
"title": "Titolo",
|
||||
"artist": "Artisto",
|
||||
"album": "Albumo",
|
||||
"path": "Dosiera vojo",
|
||||
"genre": "Ĝenro",
|
||||
"compilation": "Kompilaĵo",
|
||||
"year": "Jaro",
|
||||
"size": "Dosiera grandeco",
|
||||
"updatedAt": "Ĝisdatigita je",
|
||||
"bitRate": "Bitrapido",
|
||||
"discSubtitle": "Diska Subteksto",
|
||||
"starred": "Stela",
|
||||
"comment": "Komento",
|
||||
"rating": "",
|
||||
"quality": "",
|
||||
"bpm": "",
|
||||
"playDate": "",
|
||||
"channels": "",
|
||||
"createdAt": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ludi Poste",
|
||||
"playNow": "Ludi nun",
|
||||
"addToPlaylist": "Aldoni al Ludlisto",
|
||||
"shuffleAll": "Miksu Ĉiujn",
|
||||
"download": "Elŝuti",
|
||||
"playNext": "Ludu Poste",
|
||||
"info": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Albumo |||| Albumoj",
|
||||
"fields": {
|
||||
"albumArtist": "Albumo artista",
|
||||
"artist": "Artisto",
|
||||
"duration": "Tempo",
|
||||
"songCount": "Kantoj",
|
||||
"playCount": "Nombro de ŝkotoj",
|
||||
"name": "Nomo",
|
||||
"genre": "Genro",
|
||||
"compilation": "Kompilaĵo",
|
||||
"year": "Jaro",
|
||||
"updatedAt": "Ĝisdatigita je :",
|
||||
"comment": "Komento",
|
||||
"rating": "",
|
||||
"createdAt": "",
|
||||
"size": "",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Ludi",
|
||||
"playNext": "Ludi poste",
|
||||
"addToQueue": "Aldoni la dosieron de atento",
|
||||
"shuffle": "Miksi",
|
||||
"addToPlaylist": "Aldoni al la Ludlisto",
|
||||
"download": "Elŝuti",
|
||||
"info": "",
|
||||
"share": ""
|
||||
},
|
||||
"lists": {
|
||||
"all": "Ĉiuj",
|
||||
"random": "Hazarda",
|
||||
"recentlyAdded": "Lastatempe Aldonita",
|
||||
"recentlyPlayed": "Lastatempe Ludita",
|
||||
"mostPlayed": "Plej Luditaj",
|
||||
"starred": "Stelplena",
|
||||
"topRated": ""
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artisto |||| Artistoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"albumCount": "Nombro da albumoj",
|
||||
"songCount": "Kanto kalkula",
|
||||
"playCount": "Teatraĵoj",
|
||||
"rating": "",
|
||||
"genre": "",
|
||||
"size": ""
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Uzanto |||| Uzantoj",
|
||||
"fields": {
|
||||
"userName": "Uzantonomo",
|
||||
"isAdmin": "Estas Administranto",
|
||||
"lastLoginAt": "Lasta Ensaluto Je",
|
||||
"updatedAt": "Ĝisdatigita je",
|
||||
"name": "Nomo",
|
||||
"password": "Pasvorto",
|
||||
"createdAt": "Kreita je :",
|
||||
"changePassword": "",
|
||||
"currentPassword": "",
|
||||
"newPassword": "",
|
||||
"token": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"deleted": ""
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "",
|
||||
"clickHereForToken": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Legilo |||| Legilj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"transcodingId": "Transkodigo",
|
||||
"maxBitRate": "Maksimuma Bitrapido",
|
||||
"client": "Kliento",
|
||||
"userName": "Uzantonomo",
|
||||
"lastSeen": "Laste Vidita Je",
|
||||
"reportRealPath": "Raporti vera pado",
|
||||
"scrobbleEnabled": ""
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transkodigo |||| Transkodigoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"targetFormat": "Celformato",
|
||||
"defaultBitRate": "Defaŭlta Bitrapido",
|
||||
"command": "Komando"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Ludlisto |||| Ludlistoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"duration": "Daŭro",
|
||||
"ownerName": "Posedanto",
|
||||
"public": "Publika",
|
||||
"updatedAt": "Ĝisdatigita je",
|
||||
"createdAt": "Kreita je",
|
||||
"songCount": "Kantoj",
|
||||
"comment": "Komento",
|
||||
"sync": "Aŭtomata importado",
|
||||
"path": "Importi de"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Elektu ludliston :",
|
||||
"addNewPlaylist": "Krei \"%{name}\"",
|
||||
"export": "Eksporti",
|
||||
"makePublic": "",
|
||||
"makePrivate": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "",
|
||||
"song_exist": ""
|
||||
}
|
||||
},
|
||||
"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": "Esperanto",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Kanto |||| Kantoj",
|
||||
"fields": {
|
||||
"albumArtist": "Artisto de Albumo",
|
||||
"duration": "Daŭro",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Ludoj",
|
||||
"title": "Titolo",
|
||||
"artist": "Artisto",
|
||||
"album": "Albumo",
|
||||
"path": "Dosiera vojo",
|
||||
"genre": "Ĝenro",
|
||||
"compilation": "Kompilaĵo",
|
||||
"year": "Jaro",
|
||||
"size": "Dosiera grandeco",
|
||||
"updatedAt": "Ĝisdatigita je",
|
||||
"bitRate": "Bitrapido",
|
||||
"discSubtitle": "Diska Subteksto",
|
||||
"starred": "Stela",
|
||||
"comment": "Komento",
|
||||
"rating": "Takso",
|
||||
"quality": "Kvalito",
|
||||
"bpm": "Pulsrapideco",
|
||||
"playDate": "",
|
||||
"channels": "",
|
||||
"createdAt": "",
|
||||
"grouping": "",
|
||||
"mood": "",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": "",
|
||||
"bitDepth": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ludi Poste",
|
||||
"playNow": "Ludi nun",
|
||||
"addToPlaylist": "Aldoni al Ludlisto",
|
||||
"shuffleAll": "Miksu Ĉiujn",
|
||||
"download": "Elŝuti",
|
||||
"playNext": "Ludu Poste",
|
||||
"info": ""
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Dankon pro instalado de Navidrome !",
|
||||
"welcome2": "Por komenci, kreu administrantan uzanton",
|
||||
"confirmPassword": "Konfirmu pasvorton",
|
||||
"buttonCreateAdmin": "Krei administranto",
|
||||
"auth_check_error": "Bonvolu ensaluti por daŭrigi",
|
||||
"user_menu": "Profilo",
|
||||
"username": "Uzantnomo",
|
||||
"password": "Pasvorto",
|
||||
"sign_in": "Ensaluti",
|
||||
"sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi",
|
||||
"logout": "Elsaluti"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Bonvolu uzi nur literon kaj ciferojn",
|
||||
"passwordDoesNotMatch": "Pasvorto ne kongruas",
|
||||
"required": "Necesa",
|
||||
"minLength": "Devas esti almenaŭ %{min} signoj",
|
||||
"maxLength": "Devas esti %{max} signoj aŭ malpli",
|
||||
"minValue": "Devas esti almenaŭ %{min}",
|
||||
"maxValue": "Devas esti %{max} aŭ malpli",
|
||||
"number": "Devas esti nombro",
|
||||
"email": "Devas esti valida retpoŝto",
|
||||
"oneOf": "Devas esti unu el: %{options}",
|
||||
"regex": "Devas kongrui kun specifa formato (regexp): %{pattern}",
|
||||
"unique": "",
|
||||
"url": ""
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Aldoni filtrilon",
|
||||
"add": "Aldoni",
|
||||
"back": "Reiri",
|
||||
"bulk_actions": "1 ero elektita |||| ${smart_count} eroj elektitaj",
|
||||
"cancel": "Nuligi",
|
||||
"clear_input_value": "Viŝi valoro",
|
||||
"clone": "Kloni",
|
||||
"confirm": "Konfirmi",
|
||||
"create": "Krei",
|
||||
"delete": "Forstrekis",
|
||||
"edit": "Modifi",
|
||||
"export": "Eksporti",
|
||||
"list": "Listigi",
|
||||
"refresh": "Aktualigi",
|
||||
"remove_filter": "Forigu ĉi tiun filtrilon",
|
||||
"remove": "Forigi",
|
||||
"save": "Konservi",
|
||||
"search": "Serĉi",
|
||||
"show": "Montri",
|
||||
"sort": "Ordigi",
|
||||
"undo": "Malfari",
|
||||
"expand": "Etendi",
|
||||
"close": "Fermi",
|
||||
"open_menu": "Malfermu menuon",
|
||||
"close_menu": "Fermu menuon",
|
||||
"unselect": "Malelekti",
|
||||
"skip": "",
|
||||
"bulk_actions_mobile": "",
|
||||
"share": "",
|
||||
"download": ""
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Jes",
|
||||
"false": "Ne"
|
||||
},
|
||||
"page": {
|
||||
"create": "Krei %{name}",
|
||||
"dashboard": "Panelo",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Io fuŝiĝis",
|
||||
"list": "${name}",
|
||||
"loading": "Ŝarĝante",
|
||||
"not_found": "Ne trovita",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Ankoraŭ ne %{name}",
|
||||
"invite": "Ĉu vi volas aldoni unu?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Forĵetu iujn dosierojn por alŝuti, aŭ alklaku por elekti unu.",
|
||||
"upload_single": "Forĵetu iujn dosierojn por alŝuti, aŭ alklaku por elekti ĝin."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Faligu iujn bildojn por alŝuti, aŭ alklaku por elekti unu.",
|
||||
"upload_single": "Faligu bildon por alŝuti, aŭ alklaku por elekti ĝin."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Ne eblas trovi referencajn datumojn.",
|
||||
"many_missing": "Almenaŭ unu el la rilataj referencoj ne plu ŝajnas esti disponebla.",
|
||||
"single_missing": "Rilata referenco ne plu ŝajnas esti disponebla."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "kaŝi pasvorto",
|
||||
"toggle_hidden": "montri pasvorto"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Pri",
|
||||
"are_you_sure": "Ĉu vi certas ?",
|
||||
"bulk_delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun %{name} ? |||| Ĉu vi certas, ke vi volas forigi ĉi tiujn %{smart_count} erojn ?",
|
||||
"bulk_delete_title": "Forigi %{name} |||| Forigi %{smart_count} %{name}",
|
||||
"delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun %{smart_count} eron ?",
|
||||
"delete_title": "Forigi %{name} #%{id}",
|
||||
"details": "Detaleto",
|
||||
"error": "Klienta eraro okazis kaj via peto ne povis esti plenumita.",
|
||||
"invalid_form": "La formo ne estas valida. Bonvolu kontroli pri eraroj.",
|
||||
"loading": "La paĝo ŝarĝas, nur momenton bonvolu.",
|
||||
"no": "Ne",
|
||||
"not_found": "Aŭ vi tajpis malbonan URL, aŭ vi sekvis malbonan ligon.",
|
||||
"yes": "Jes",
|
||||
"unsaved_changes": "Luj el viaj ŝanĝoj ne estis konservitaj. Ĉu vi estas certa, ke vi volas ignori ilin?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Neniu rezulto troviĝis",
|
||||
"no_more_results": "La paĝa numero %{page} estas ekster limoj. Provu la antaŭan paĝon.",
|
||||
"page_out_of_boundaries": "Paĝa numero %{page} ekster limoj",
|
||||
"page_out_from_end": "Ne povas iri post la lasta paĝo",
|
||||
"page_out_from_begin": "Ne povas iri antaŭ paĝo 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}",
|
||||
"page_rows_per_page": "Eroj por paĝo:",
|
||||
"next": "Poste",
|
||||
"prev": "Antaŭ",
|
||||
"skip_nav": "Preterlasu al enhavo"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Elemento ĝisdatigita |||| %{smart_count} elementoj ĝisdatigitaj",
|
||||
"created": "\nElemento kretia",
|
||||
"deleted": "Elemento foriga |||| %{smart_count} elementoj forigaj",
|
||||
"bad_item": "Elemento malkorekta",
|
||||
"item_doesnt_exist": "Elemento ne ekzistas",
|
||||
"http_error": "Servila komunikada eraro",
|
||||
"data_provider_error": "datumaProvizora eraro. Kontrolu la konzolon por detaloj.",
|
||||
"i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo",
|
||||
"canceled": "Ago nuligita",
|
||||
"logged_out": "Via seanco finiĝis, bonvolu rekonekti.",
|
||||
"new_version": ""
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "",
|
||||
"layout": "",
|
||||
"grid": "",
|
||||
"table": ""
|
||||
}
|
||||
"album": {
|
||||
"name": "Albumo |||| Albumoj",
|
||||
"fields": {
|
||||
"albumArtist": "Artisto de Albumo",
|
||||
"artist": "Artisto",
|
||||
"duration": "Tempo",
|
||||
"songCount": "Kantoj",
|
||||
"playCount": "Ludoj",
|
||||
"name": "Nomo",
|
||||
"genre": "Ĝenro",
|
||||
"compilation": "Kompilaĵo",
|
||||
"year": "Jaro",
|
||||
"updatedAt": "Ĝisdatigita je :",
|
||||
"comment": "Komento",
|
||||
"rating": "Takso",
|
||||
"createdAt": "",
|
||||
"size": "",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": "",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": "",
|
||||
"date": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Ludi",
|
||||
"playNext": "Ludi Sekvante",
|
||||
"addToQueue": "Aldoni la dosieron de atento",
|
||||
"shuffle": "Miksi",
|
||||
"addToPlaylist": "Aldoni al la Ludlisto",
|
||||
"download": "Elŝuti",
|
||||
"info": "",
|
||||
"share": ""
|
||||
},
|
||||
"lists": {
|
||||
"all": "Ĉiuj",
|
||||
"random": "Hazarda",
|
||||
"recentlyAdded": "Lastatempe Aldonita",
|
||||
"recentlyPlayed": "Lastatempe Ludita",
|
||||
"mostPlayed": "Plej Luditaj",
|
||||
"starred": "Stelplena",
|
||||
"topRated": "Plej Alte Taksite"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "Noto",
|
||||
"transcodingDisabled": "Ŝanĝi la transkodigan agordon per la interreta interfaco estas malebligita pro sekurecaj kialoj. Se vi ŝatus ŝanĝi (redakti aŭ aldoni) transkodigajn opciojn, relanĉu la servilon per la agordo %{config}.",
|
||||
"transcodingEnabled": "Navidrome nuntempe funkcias kun %{config}, ebligante lanĉi sistemajn komandojn de la transkodigaj agordoj per la interreta interfaco. Ni rekomendas malŝalti ĝin pro sekurecaj kialoj kaj ebligi ĝin nur dum agordo de Transkodigaj opcioj.",
|
||||
"songsAddedToPlaylist": "Aldonis 1 kanton al ludlisto |||| Aldonis %{smart_count} kantojn al ludlisto",
|
||||
"noPlaylistsAvailable": "Neniu disponebla",
|
||||
"delete_user_title": "Forigi uzanto '%{name}'",
|
||||
"delete_user_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun uzanton kaj ĉiujn iliajn datumojn (inkluzive ludlistojn kaj preferojn) ?",
|
||||
"notifications_blocked": "Vi blokis sciigojn por ĉi tiu retejo en la agordoj de via retumilo",
|
||||
"notifications_not_available": "Ĉi tiu retumilo ne subtenas labortablajn sciigojn aŭ vi ne aliras Navidrome per https",
|
||||
"lastfmLinkSuccess": "",
|
||||
"lastfmLinkFailure": "",
|
||||
"lastfmUnlinkSuccess": "",
|
||||
"lastfmUnlinkFailure": "",
|
||||
"openIn": {
|
||||
"lastfm": "",
|
||||
"musicbrainz": ""
|
||||
},
|
||||
"lastfmLink": "",
|
||||
"listenBrainzLinkSuccess": "",
|
||||
"listenBrainzLinkFailure": "",
|
||||
"listenBrainzUnlinkSuccess": "",
|
||||
"listenBrainzUnlinkFailure": "",
|
||||
"downloadOriginalFormat": "",
|
||||
"shareOriginalFormat": "",
|
||||
"shareDialogTitle": "",
|
||||
"shareBatchDialogTitle": "",
|
||||
"shareSuccess": "",
|
||||
"shareFailure": "",
|
||||
"downloadDialogTitle": "",
|
||||
"shareCopyToClipboard": ""
|
||||
"artist": {
|
||||
"name": "Artisto |||| Artistoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"albumCount": "Nombro da albumoj",
|
||||
"songCount": "Kanto kalkula",
|
||||
"playCount": "Teatraĵoj",
|
||||
"rating": "Takso",
|
||||
"genre": "",
|
||||
"size": "",
|
||||
"role": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteko",
|
||||
"settings": "Agordoj",
|
||||
"version": "Versio",
|
||||
"theme": "Temo",
|
||||
"personal": {
|
||||
"name": "Persona",
|
||||
"options": {
|
||||
"theme": "Temo",
|
||||
"language": "Lingvo",
|
||||
"defaultView": "Defaŭlta Vido",
|
||||
"desktop_notifications": "Labortablaj sciigoj",
|
||||
"lastfmScrobbling": "",
|
||||
"listenBrainzScrobbling": "",
|
||||
"replaygain": "",
|
||||
"preAmp": "",
|
||||
"gain": {
|
||||
"none": "",
|
||||
"album": "",
|
||||
"track": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albumoj",
|
||||
"about": "Pri",
|
||||
"playlists": "",
|
||||
"sharedPlaylists": ""
|
||||
"user": {
|
||||
"name": "Uzanto |||| Uzantoj",
|
||||
"fields": {
|
||||
"userName": "Uzantnomo",
|
||||
"isAdmin": "Estas Administranto",
|
||||
"lastLoginAt": "Antaŭa Ensaluto Je",
|
||||
"updatedAt": "Ĝisdatigita je",
|
||||
"name": "Nomo",
|
||||
"password": "Pasvorto",
|
||||
"createdAt": "Kreita je :",
|
||||
"changePassword": "Ĉu Ŝanĝi Pasvorton?",
|
||||
"currentPassword": "Nuna Pasvorto",
|
||||
"newPassword": "Nova Pasvorto",
|
||||
"token": "",
|
||||
"lastAccessAt": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Uzanto farita",
|
||||
"updated": "Uzanto ĝistadigita",
|
||||
"deleted": "Uzanto forigita"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "",
|
||||
"clickHereForToken": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Ludu Atendon",
|
||||
"openText": "Malfermi",
|
||||
"closeText": "Fermi",
|
||||
"notContentText": "Neniu Muziko",
|
||||
"clickToPlayText": "Alklaku por ludi",
|
||||
"clickToPauseText": "Alklaku por paŭzi",
|
||||
"nextTrackText": "Sekva muziko",
|
||||
"previousTrackText": "Antaŭa muziko",
|
||||
"reloadText": "Reŝargi",
|
||||
"volumeText": "Laŭteco",
|
||||
"toggleLyricText": "Baskuligi paroloj",
|
||||
"toggleMiniModeText": "Minimumigi",
|
||||
"destroyText": "Detrui",
|
||||
"downloadText": "Elŝuti",
|
||||
"removeAudioListsText": "Forigi sonlistojn",
|
||||
"clickToDeleteText": "Alklaku por forigi %{name}",
|
||||
"emptyLyricText": "Neniaj paroloj",
|
||||
"playModeText": {
|
||||
"order": "En ordo",
|
||||
"orderLoop": "Ripeti",
|
||||
"singleLoop": "Ripeti Unu",
|
||||
"shufflePlay": "Miksi"
|
||||
}
|
||||
"name": "Ludanto |||| Ludantoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"transcodingId": "Transkodigo",
|
||||
"maxBitRate": "Maksimuma Bitrapido",
|
||||
"client": "Kliento",
|
||||
"userName": "Uzantnomo",
|
||||
"lastSeen": "Laste Vidita Je",
|
||||
"reportRealPath": "Raporti vera pado",
|
||||
"scrobbleEnabled": ""
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Hejmpaĝo",
|
||||
"source": "Fontkodo",
|
||||
"featureRequests": "Trajta peto"
|
||||
}
|
||||
"transcoding": {
|
||||
"name": "Transkodigo |||| Transkodigoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"targetFormat": "Cela Formato",
|
||||
"defaultBitRate": "Defaŭlta Bitrapido",
|
||||
"command": "Komando"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktiveco",
|
||||
"totalScanned": "Entute dosierujoj skanitaj",
|
||||
"quickScan": "Rapida Skanado",
|
||||
"fullScan": "Plena Skanado",
|
||||
"serverUptime": "Servila daŭro de funkciado",
|
||||
"serverDown": "SENKONEKTA"
|
||||
"playlist": {
|
||||
"name": "Ludlisto |||| Ludlistoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"duration": "Daŭro",
|
||||
"ownerName": "Posedanto",
|
||||
"public": "Publika",
|
||||
"updatedAt": "Ĝisdatigita je",
|
||||
"createdAt": "Kreita je",
|
||||
"songCount": "Kantoj",
|
||||
"comment": "Komento",
|
||||
"sync": "Aŭtomata importado",
|
||||
"path": "Importi de"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Elektu ludliston :",
|
||||
"addNewPlaylist": "Krei \"%{name}\"",
|
||||
"export": "Eksporti",
|
||||
"makePublic": "",
|
||||
"makePrivate": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Aldoni duobligitajn kantojn",
|
||||
"song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome klavkomando",
|
||||
"hotkeys": {
|
||||
"show_help": "Montru ĉi tiun helpon",
|
||||
"toggle_menu": "Baskuli menuan flankobreton",
|
||||
"toggle_play": "Ludi / Paŭzi",
|
||||
"prev_song": "Antaŭa kanto",
|
||||
"next_song": "Sekva kanto",
|
||||
"vol_up": "Pli volumo",
|
||||
"vol_down": "Malpli volumo",
|
||||
"toggle_love": "Baskuli la stelon de nuna kanto",
|
||||
"current_song": ""
|
||||
}
|
||||
"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": ""
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"path": "",
|
||||
"size": "",
|
||||
"updatedAt": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
},
|
||||
"empty": ""
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Dankon pro instalado de Navidrome !",
|
||||
"welcome2": "Por komenci, kreu administrantan uzanton",
|
||||
"confirmPassword": "Konfirmu Pasvorton",
|
||||
"buttonCreateAdmin": "Krei Administranto",
|
||||
"auth_check_error": "Bonvolu ensaluti por daŭrigi",
|
||||
"user_menu": "Profilo",
|
||||
"username": "Uzantnomo",
|
||||
"password": "Pasvorto",
|
||||
"sign_in": "Ensaluti",
|
||||
"sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi",
|
||||
"logout": "Elsaluti",
|
||||
"insightsCollectionNote": ""
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Bonvolu uzi nur literojn kaj ciferojn",
|
||||
"passwordDoesNotMatch": "Pasvorto ne kongruas",
|
||||
"required": "Necesa",
|
||||
"minLength": "Devas esti almenaŭ %{min} signoj",
|
||||
"maxLength": "Devas esti %{max} signoj aŭ malpli",
|
||||
"minValue": "Devas esti almenaŭ %{min}",
|
||||
"maxValue": "Devas esti %{max} aŭ malpli",
|
||||
"number": "Devas esti nombro",
|
||||
"email": "Devas esti valida retpoŝto",
|
||||
"oneOf": "Devas esti unu el: %{options}",
|
||||
"regex": "Devas kongrui kun specifa formato (regexp): %{pattern}",
|
||||
"unique": "Devas esti unika",
|
||||
"url": ""
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Aldoni filtrilon",
|
||||
"add": "Aldoni",
|
||||
"back": "Reiri",
|
||||
"bulk_actions": "1 ero elektita |||| ${smart_count} eroj elektitaj",
|
||||
"cancel": "Nuligi",
|
||||
"clear_input_value": "Viŝi valoron",
|
||||
"clone": "Kloni",
|
||||
"confirm": "Konfirmi",
|
||||
"create": "Krei",
|
||||
"delete": "Forigi",
|
||||
"edit": "Redakti",
|
||||
"export": "Eksporti",
|
||||
"list": "Listigi",
|
||||
"refresh": "Aktualigi",
|
||||
"remove_filter": "Forigu ĉi tiun filtrilon",
|
||||
"remove": "Forigi",
|
||||
"save": "Konservi",
|
||||
"search": "Serĉi",
|
||||
"show": "Montri",
|
||||
"sort": "Ordigi",
|
||||
"undo": "Malfari",
|
||||
"expand": "Etendi",
|
||||
"close": "Fermi",
|
||||
"open_menu": "Malfermi menuon",
|
||||
"close_menu": "Fermu menuon",
|
||||
"unselect": "Malelekti",
|
||||
"skip": "Pasigi",
|
||||
"bulk_actions_mobile": "",
|
||||
"share": "",
|
||||
"download": ""
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Jes",
|
||||
"false": "Ne"
|
||||
},
|
||||
"page": {
|
||||
"create": "Krei %{name}",
|
||||
"dashboard": "Panelo",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Io fuŝiĝis",
|
||||
"list": "${name}",
|
||||
"loading": "Ŝarĝante",
|
||||
"not_found": "Ne Trovita",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Ankoraŭ ne %{name}",
|
||||
"invite": "Ĉu vi volas aldoni unu?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Demetu iom da dosieroj por alŝuti, aŭ alklaku por elekti unu.",
|
||||
"upload_single": "Demetu iom da dosieroj por alŝuti, aŭ alklaku por elekti ĝin."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Demetu iom da bildoj por alŝuti, aŭ alklaku por elekti unu.",
|
||||
"upload_single": "Demetu bildon por alŝuti, aŭ alklaku por elekti ĝin."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Ne eblas trovi referencajn datumojn.",
|
||||
"many_missing": "Almenaŭ unu el la rilataj referencoj ne plu ŝajnas esti disponebla.",
|
||||
"single_missing": "Rilata referenco ne plu ŝajnas esti disponebla."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Kaŝi pasvorton",
|
||||
"toggle_hidden": "Montri pasvorton"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Pri",
|
||||
"are_you_sure": "Ĉu vi certas?",
|
||||
"bulk_delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun %{name}? |||| Ĉu vi certas, ke vi volas forigi ĉi tiujn %{smart_count} erojn?",
|
||||
"bulk_delete_title": "Forigi %{name} |||| Forigi %{smart_count} %{name}",
|
||||
"delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun eron?",
|
||||
"delete_title": "Forigi %{name} #%{id}",
|
||||
"details": "Detaloj",
|
||||
"error": "Klienta eraro okazis kaj via peto ne povis esti plenumita.",
|
||||
"invalid_form": "La formo ne estas valida. Bonvolu kontroli pri eraroj.",
|
||||
"loading": "La paĝo ŝargiĝas, atendu nur momenton bonvole",
|
||||
"no": "Ne",
|
||||
"not_found": "Aŭ vi tajpis malĝustan ligilon, aŭ vi sekvis malbonan ligilon.",
|
||||
"yes": "Jes",
|
||||
"unsaved_changes": "Iuj el viaj ŝanĝoj ne estis konservitaj. Ĉu vi certas, ke vi volas ignori ilin?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Neniu rezulto troviĝis",
|
||||
"no_more_results": "La paĝa numero %{page} estas ekster limoj. Provu la antaŭan paĝon.",
|
||||
"page_out_of_boundaries": "Paĝa numero %{page} estas ekster limoj",
|
||||
"page_out_from_end": "Ne povas iri post la lasta paĝo",
|
||||
"page_out_from_begin": "Ne povas iri antaŭ paĝo 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}",
|
||||
"page_rows_per_page": "Eroj en paĝo:",
|
||||
"next": "Sekvanta",
|
||||
"prev": "Antaŭa",
|
||||
"skip_nav": "Preterlasu al enhavo"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Elemento ĝisdatigita |||| %{smart_count} elementoj ĝisdatigitaj",
|
||||
"created": "\nElemento kretia",
|
||||
"deleted": "Elemento foriga |||| %{smart_count} elementoj forigaj",
|
||||
"bad_item": "Malĝusta elemento",
|
||||
"item_doesnt_exist": "Elemento ne ekzistas",
|
||||
"http_error": "Servila komunikada eraro",
|
||||
"data_provider_error": "datumaProvizora eraro. Kontrolu la konzolon por detaloj.",
|
||||
"i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo",
|
||||
"canceled": "Ago nuligita",
|
||||
"logged_out": "Via seanco finiĝis, bonvolu rekonekti.",
|
||||
"new_version": ""
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "",
|
||||
"layout": "Aranĝo",
|
||||
"grid": "Krado",
|
||||
"table": ""
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "Noto",
|
||||
"transcodingDisabled": "Ŝanĝi la transkodigan agordon per la interreta interfaco estas malebligita pro sekurecaj kialoj. Se vi ŝatus ŝanĝi (redakti aŭ aldoni) transkodigajn opciojn, relanĉu la servilon per la agordo %{config}.",
|
||||
"transcodingEnabled": "Navidrome nuntempe funkcias kun %{config}, ebligante lanĉi sistemajn komandojn de la transkodigaj agordoj per la interreta interfaco. Ni rekomendas malŝalti ĝin pro sekurecaj kialoj kaj ebligi ĝin nur dum agordo de Transkodigaj opcioj.",
|
||||
"songsAddedToPlaylist": "Aldonis 1 kanton al ludlisto |||| Aldonis %{smart_count} kantojn al ludlisto",
|
||||
"noPlaylistsAvailable": "Neniu disponebla",
|
||||
"delete_user_title": "Forigi uzanto '%{name}'",
|
||||
"delete_user_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun uzanton kaj ĉiujn iliajn datumojn (inkluzive ludlistojn kaj preferojn) ?",
|
||||
"notifications_blocked": "Vi blokis sciigojn por ĉi tiu retejo en la agordoj de via retumilo",
|
||||
"notifications_not_available": "Ĉi tiu retumilo ne subtenas labortablajn sciigojn aŭ vi ne aliras Navidrome per https",
|
||||
"lastfmLinkSuccess": "",
|
||||
"lastfmLinkFailure": "",
|
||||
"lastfmUnlinkSuccess": "",
|
||||
"lastfmUnlinkFailure": "",
|
||||
"openIn": {
|
||||
"lastfm": "",
|
||||
"musicbrainz": ""
|
||||
},
|
||||
"lastfmLink": "",
|
||||
"listenBrainzLinkSuccess": "",
|
||||
"listenBrainzLinkFailure": "",
|
||||
"listenBrainzUnlinkSuccess": "",
|
||||
"listenBrainzUnlinkFailure": "",
|
||||
"downloadOriginalFormat": "",
|
||||
"shareOriginalFormat": "",
|
||||
"shareDialogTitle": "",
|
||||
"shareBatchDialogTitle": "",
|
||||
"shareSuccess": "",
|
||||
"shareFailure": "",
|
||||
"downloadDialogTitle": "",
|
||||
"shareCopyToClipboard": "",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteko",
|
||||
"settings": "Agordoj",
|
||||
"version": "Versio",
|
||||
"theme": "Etoso",
|
||||
"personal": {
|
||||
"name": "Persona",
|
||||
"options": {
|
||||
"theme": "Etoso",
|
||||
"language": "Lingvo",
|
||||
"defaultView": "Defaŭlta Vido",
|
||||
"desktop_notifications": "Labortablaj sciigoj",
|
||||
"lastfmScrobbling": "",
|
||||
"listenBrainzScrobbling": "",
|
||||
"replaygain": "",
|
||||
"preAmp": "",
|
||||
"gain": {
|
||||
"none": "",
|
||||
"album": "",
|
||||
"track": ""
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
}
|
||||
},
|
||||
"albumList": "Albumoj",
|
||||
"about": "Pri",
|
||||
"playlists": "",
|
||||
"sharedPlaylists": ""
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Atendovico",
|
||||
"openText": "Malfermi",
|
||||
"closeText": "Fermi",
|
||||
"notContentText": "Neniu muziko",
|
||||
"clickToPlayText": "Alklaku por ludi",
|
||||
"clickToPauseText": "Alklaku por paŭzi",
|
||||
"nextTrackText": "Sekvanta kanto",
|
||||
"previousTrackText": "Antaŭa kanto",
|
||||
"reloadText": "Reŝargi",
|
||||
"volumeText": "Laŭteco",
|
||||
"toggleLyricText": "Baskuligi kantotekston",
|
||||
"toggleMiniModeText": "Minimumigi",
|
||||
"destroyText": "Detrui",
|
||||
"downloadText": "Elŝuti",
|
||||
"removeAudioListsText": "Forigi sonlistojn",
|
||||
"clickToDeleteText": "Alklaku por forigi %{name}",
|
||||
"emptyLyricText": "Neniu kantoteksto",
|
||||
"playModeText": {
|
||||
"order": "Laŭorde",
|
||||
"orderLoop": "Ripeti",
|
||||
"singleLoop": "Ripeti Unufoje",
|
||||
"shufflePlay": "Miksi"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Hejmpaĝo",
|
||||
"source": "Fontkodo",
|
||||
"featureRequests": "Trajta peto",
|
||||
"lastInsightsCollection": "",
|
||||
"insights": {
|
||||
"disabled": "",
|
||||
"waiting": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktiveco",
|
||||
"totalScanned": "Entute dosierujoj skanitaj",
|
||||
"quickScan": "Rapida Skanado",
|
||||
"fullScan": "Plena Skanado",
|
||||
"serverUptime": "Servila daŭro de funkciado",
|
||||
"serverDown": "SENKONEKTA"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome klavkomando",
|
||||
"hotkeys": {
|
||||
"show_help": "Montru ĉi tiun helpon",
|
||||
"toggle_menu": "Baskuli menuan flankobreton",
|
||||
"toggle_play": "Ludi / Paŭzi",
|
||||
"prev_song": "Antaŭa kanto",
|
||||
"next_song": "Sekva kanto",
|
||||
"vol_up": "Pli volumo",
|
||||
"vol_down": "Malpli volumo",
|
||||
"toggle_love": "Baskuli la stelon de nuna kanto",
|
||||
"current_song": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,12 @@
|
||||
"playDate": "Últimas reproducciones",
|
||||
"channels": "Canales",
|
||||
"createdAt": "Creado el",
|
||||
"grouping": "",
|
||||
"grouping": "Agrupación",
|
||||
"mood": "",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": ""
|
||||
"participants": "Participantes",
|
||||
"tags": "Etiquetas",
|
||||
"mappedTags": "Etiquetas asignadas",
|
||||
"rawTags": "Etiquetas sin procesar"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Reproducir después",
|
||||
@@ -65,10 +65,10 @@
|
||||
"releaseDate": "Publicado",
|
||||
"releases": "Lanzamiento |||| Lanzamientos",
|
||||
"released": "Publicado",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"grouping": "",
|
||||
"recordLabel": "Discográfica",
|
||||
"catalogNum": "Número de catálogo",
|
||||
"releaseType": "Tipo de lanzamiento",
|
||||
"grouping": "Agrupación",
|
||||
"media": "",
|
||||
"mood": ""
|
||||
},
|
||||
@@ -76,7 +76,7 @@
|
||||
"playAll": "Reproducir",
|
||||
"playNext": "Reproducir siguiente",
|
||||
"addToQueue": "Reproducir después",
|
||||
"shuffle": "Aletorio",
|
||||
"shuffle": "Aleatorio",
|
||||
"addToPlaylist": "Agregar a la lista",
|
||||
"download": "Descargar",
|
||||
"info": "Obtener información",
|
||||
@@ -102,22 +102,22 @@
|
||||
"rating": "Calificación",
|
||||
"genre": "Género",
|
||||
"size": "Tamaño",
|
||||
"role": ""
|
||||
"role": "Rol"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
"albumartist": "Artista del álbum",
|
||||
"artist": "Artista",
|
||||
"composer": "Compositor",
|
||||
"conductor": "Director de orquesta",
|
||||
"lyricist": "Letrista",
|
||||
"arranger": "Arreglista",
|
||||
"producer": "Productor",
|
||||
"director": "Director",
|
||||
"engineer": "Ingeniero de sonido",
|
||||
"mixer": "Mezclador",
|
||||
"remixer": "Remixer",
|
||||
"djmixer": "DJ Mixer",
|
||||
"performer": "Intérprete"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -141,7 +141,7 @@
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Usuario creado",
|
||||
"updated": "Usuario actulalizado",
|
||||
"updated": "Usuario actualizado",
|
||||
"deleted": "Usuario eliminado"
|
||||
},
|
||||
"message": {
|
||||
@@ -228,17 +228,17 @@
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "",
|
||||
"name": "Faltante",
|
||||
"fields": {
|
||||
"path": "",
|
||||
"size": "",
|
||||
"updatedAt": ""
|
||||
"path": "Ruta",
|
||||
"size": "Tamaño",
|
||||
"updatedAt": "Actualizado el"
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
"remove": "Eliminar"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
"removed": "Eliminado"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -413,12 +413,12 @@
|
||||
"downloadOriginalFormat": "Descargar formato original",
|
||||
"shareOriginalFormat": "Compartir formato original",
|
||||
"shareDialogTitle": "Compartir %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Compartir 1 %{resource} |||| Share %{smart_count} %{resource}",
|
||||
"shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}",
|
||||
"shareSuccess": "URL copiada al portapapeles: %{url}",
|
||||
"shareFailure": "Error al copiar la URL %{url} al portapapeles",
|
||||
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_title": "Eliminar elemento faltante",
|
||||
"remove_missing_content": ""
|
||||
},
|
||||
"menu": {
|
||||
@@ -509,4 +509,4 @@
|
||||
"current_song": "Canción actual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,7 @@
|
||||
"username": "Partekatzailea:",
|
||||
"url": "URLa",
|
||||
"description": "Deskribapena",
|
||||
"downloadable": "Deskargatzea ahalbidetu?",
|
||||
"contents": "Edukia",
|
||||
"expiresAt": "Iraungitze-data:",
|
||||
"lastVisitedAt": "Azkenekoz bisitatu zen:",
|
||||
@@ -223,22 +224,24 @@
|
||||
"format": "Formatua",
|
||||
"maxBitRate": "Gehienezko bit tasa",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"createdAt": "Sortze-data:",
|
||||
"downloadable": "Deskargatzea ahalbidetu?"
|
||||
}
|
||||
"createdAt": "Sortze-data:"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "",
|
||||
"name": "Fitxategia falta da|||| Fitxategiak falta dira",
|
||||
"empty": "Ez da fitxategirik falta",
|
||||
"fields": {
|
||||
"path": "",
|
||||
"size": "",
|
||||
"updatedAt": ""
|
||||
"path": "Bidea",
|
||||
"size": "Tamaina",
|
||||
"updatedAt": "Desagertze-data:"
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
"remove": "Kendu"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
"removed": "Faltan zeuden fitxategiak kendu dira"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -509,4 +512,4 @@
|
||||
"current_song": "Uneko abestia"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,14 @@
|
||||
"bpm": "BPM",
|
||||
"playDate": "Derniers joués",
|
||||
"channels": "Canaux",
|
||||
"createdAt": "Date d'ajout"
|
||||
"createdAt": "Date d'ajout",
|
||||
"grouping": "Regroupement",
|
||||
"mood": "Humeur",
|
||||
"participants": "Participants supplémentaires",
|
||||
"tags": "Étiquettes supplémentaires",
|
||||
"mappedTags": "Étiquettes correspondantes",
|
||||
"rawTags": "Étiquettes brutes",
|
||||
"bitDepth": "Profondeur de bit"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ajouter à la file",
|
||||
@@ -58,7 +65,13 @@
|
||||
"originalDate": "Original",
|
||||
"releaseDate": "Sortie",
|
||||
"releases": "Sortie |||| Sorties",
|
||||
"released": "Sortie"
|
||||
"released": "Sortie",
|
||||
"recordLabel": "Label",
|
||||
"catalogNum": "Numéro de catalogue",
|
||||
"releaseType": "Type",
|
||||
"grouping": "Regroupement",
|
||||
"media": "Média",
|
||||
"mood": "Humeur"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Lire",
|
||||
@@ -89,7 +102,23 @@
|
||||
"playCount": "Lectures",
|
||||
"rating": "Classement",
|
||||
"genre": "Genre",
|
||||
"size": "Taille"
|
||||
"size": "Taille",
|
||||
"role": "Rôle"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artiste de l'album |||| Artistes de l'album",
|
||||
"artist": "Artiste |||| Artistes",
|
||||
"composer": "Compositeur |||| Compositeurs",
|
||||
"conductor": "Chef d'orchestre |||| Chefs d'orchestre",
|
||||
"lyricist": "Parolier |||| Paroliers",
|
||||
"arranger": "Arrangeur |||| Arrangeurs",
|
||||
"producer": "Producteur |||| Producteurs",
|
||||
"director": "Réalisateur |||| Réalisateurs",
|
||||
"engineer": "Ingénieur |||| Ingénieurs",
|
||||
"mixer": "Mixeur |||| Mixeurs",
|
||||
"remixer": "Remixeur |||| Remixeurs",
|
||||
"djmixer": "Mixeur DJ |||| Mixeurs DJ",
|
||||
"performer": "Interprète |||| Interprètes"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -152,7 +181,7 @@
|
||||
"public": "Publique",
|
||||
"updatedAt": "Mise à jour le",
|
||||
"createdAt": "Créée le",
|
||||
"songCount": "Titres",
|
||||
"songCount": "Morceaux",
|
||||
"comment": "Commentaire",
|
||||
"sync": "Import automatique",
|
||||
"path": "Importer depuis"
|
||||
@@ -198,6 +227,21 @@
|
||||
"createdAt": "Créé le",
|
||||
"downloadable": "Autoriser les téléchargements ?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Fichier manquant|||| Fichiers manquants",
|
||||
"fields": {
|
||||
"path": "Chemin",
|
||||
"size": "Taille",
|
||||
"updatedAt": "A disparu le"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Supprimer"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Fichier(s) manquant(s) supprimé(s)"
|
||||
},
|
||||
"empty": "Aucun fichier manquant"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -273,10 +317,10 @@
|
||||
"error": "Un problème est survenu",
|
||||
"list": "%{name}",
|
||||
"loading": "Chargement",
|
||||
"not_found": "Page manquante",
|
||||
"not_found": "Introuvable",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Pas encore de %{name}.",
|
||||
"invite": "Voulez-vous en créer ?"
|
||||
"invite": "Voulez-vous en créer un ?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
@@ -375,7 +419,9 @@
|
||||
"shareSuccess": "Lien copié vers le presse-papier : %{url}",
|
||||
"shareFailure": "Erreur en copiant le lien %{url} vers le presse-papier",
|
||||
"downloadDialogTitle": "Télécharger %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copier vers le presse-papier : Ctrl+C, Enter"
|
||||
"shareCopyToClipboard": "Copier vers le presse-papier : Ctrl+C, Enter",
|
||||
"remove_missing_title": "Supprimer les fichiers manquants",
|
||||
"remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothèque",
|
||||
|
||||
@@ -53,12 +53,12 @@
|
||||
"updatedAt": "Ultimo aggiornamento",
|
||||
"comment": "Commento",
|
||||
"rating": "Valutazione",
|
||||
"createdAt": "",
|
||||
"size": "",
|
||||
"createdAt": "Data di creazione",
|
||||
"size": "Dimensione",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": ""
|
||||
"releaseDate": "Data di pubblicazione",
|
||||
"releases": "Pubblicazione |||| Pubblicazioni",
|
||||
"released": "Pubblicato"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Riproduci",
|
||||
@@ -68,7 +68,7 @@
|
||||
"addToPlaylist": "Aggiungi alla Playlist",
|
||||
"download": "Scarica",
|
||||
"info": "Informazioni",
|
||||
"share": ""
|
||||
"share": "Condividi"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Tutti",
|
||||
@@ -89,7 +89,7 @@
|
||||
"playCount": "Riproduzioni",
|
||||
"rating": "Valutazione",
|
||||
"genre": "Genere",
|
||||
"size": ""
|
||||
"size": "Dimensione"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -160,8 +160,8 @@
|
||||
"selectPlaylist": "Aggiungi tracce alla playlist:",
|
||||
"addNewPlaylist": "Aggiungi \"%{name}\"",
|
||||
"export": "Esporta",
|
||||
"makePublic": "",
|
||||
"makePrivate": ""
|
||||
"makePublic": "Rendi Pubblica",
|
||||
"makePrivate": "Rendi Privata"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Aggiungere i duplicati",
|
||||
@@ -169,9 +169,9 @@
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "",
|
||||
"name": "Radio |||| Radio",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"name": "Nome",
|
||||
"streamUrl": "",
|
||||
"homePageUrl": "",
|
||||
"updatedAt": "",
|
||||
|
||||
@@ -1,460 +1,518 @@
|
||||
{
|
||||
"languageName": "한국어",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "노래 |||| 노래들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"duration": "시간",
|
||||
"trackNumber": "#",
|
||||
"playCount": "재생 횟수",
|
||||
"title": "제목",
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"size": "파일 크기",
|
||||
"updatedAt": "업데이트됨",
|
||||
"bitRate": "비트레이트",
|
||||
"discSubtitle": "디스크 서브타이틀",
|
||||
"starred": "즐겨찾기",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"quality": "품질",
|
||||
"bpm": "BPM",
|
||||
"playDate": "마지막 재생",
|
||||
"channels": "채널",
|
||||
"createdAt": "추가된 날짜"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "나중에 재생",
|
||||
"playNow": "지금 재생",
|
||||
"addToPlaylist": "재생목록에 추가",
|
||||
"shuffleAll": "모든 노래 셔플",
|
||||
"download": "다운로드",
|
||||
"playNext": "다음 재생",
|
||||
"info": "정보"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "앨범 |||| 앨범들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"artist": "아티스트",
|
||||
"duration": "시간",
|
||||
"songCount": "노래",
|
||||
"playCount": "재생 횟수",
|
||||
"name": "이름",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"updatedAt": "업데이트됨",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"createdAt": "추가된 날짜",
|
||||
"size": "크기",
|
||||
"originalDate": "오리지널",
|
||||
"releaseDate": "발매일",
|
||||
"releases": "발매 음반 |||| 발매 음반들",
|
||||
"released": "발매됨"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "재생",
|
||||
"playNext": "다음에 재생",
|
||||
"addToQueue": "나중에 재생",
|
||||
"shuffle": "셔플",
|
||||
"addToPlaylist": "재생목록 추가",
|
||||
"download": "다운로드",
|
||||
"info": "정보",
|
||||
"share": "공유"
|
||||
},
|
||||
"lists": {
|
||||
"all": "모두",
|
||||
"random": "랜덤",
|
||||
"recentlyAdded": "최근 추가됨",
|
||||
"recentlyPlayed": "최근 재생됨",
|
||||
"mostPlayed": "가장 많이 재생됨",
|
||||
"starred": "즐겨찾기",
|
||||
"topRated": "높은 평가"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "아티스트 |||| 아티스트들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"albumCount": "앨범 수",
|
||||
"songCount": "노래 수",
|
||||
"playCount": "재생 횟수",
|
||||
"rating": "평가",
|
||||
"genre": "장르",
|
||||
"size": "크기"
|
||||
}
|
||||
},
|
||||
"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": "설명",
|
||||
"contents": "컨텐츠",
|
||||
"expiresAt": "만료",
|
||||
"lastVisitedAt": "마지막 방문",
|
||||
"visitCount": "방문 수",
|
||||
"format": "포맷",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"downloadable": "다운로드를 허용할까요?"
|
||||
}
|
||||
}
|
||||
"languageName": "한국어",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "노래 |||| 노래들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"duration": "시간",
|
||||
"trackNumber": "#",
|
||||
"playCount": "재생 횟수",
|
||||
"title": "제목",
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"size": "파일 크기",
|
||||
"updatedAt": "업데이트됨",
|
||||
"bitRate": "비트레이트",
|
||||
"bitDepth": "비트 심도",
|
||||
"sampleRate": "샘플레이트",
|
||||
"channels": "채널",
|
||||
"discSubtitle": "디스크 서브타이틀",
|
||||
"starred": "즐겨찾기",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"quality": "품질",
|
||||
"bpm": "BPM",
|
||||
"playDate": "마지막 재생",
|
||||
"createdAt": "추가된 날짜",
|
||||
"grouping": "그룹",
|
||||
"mood": "분위기",
|
||||
"participants": "추가 참가자",
|
||||
"tags": "추가 태그",
|
||||
"mappedTags": "매핑된 태그",
|
||||
"rawTags": "원시 태그"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "나중에 재생",
|
||||
"playNow": "지금 재생",
|
||||
"addToPlaylist": "재생목록에 추가",
|
||||
"shuffleAll": "모든 노래 셔플",
|
||||
"download": "다운로드",
|
||||
"playNext": "다음 재생",
|
||||
"info": "정보 얻기"
|
||||
}
|
||||
},
|
||||
"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} 개 항목이 선택되었음",
|
||||
"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": "건너뛰기",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"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": "첫 페이지 앞으로 갈 수 없음",
|
||||
"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": "표"
|
||||
}
|
||||
"album": {
|
||||
"name": "앨범 |||| 앨범들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"artist": "아티스트",
|
||||
"duration": "시간",
|
||||
"songCount": "노래",
|
||||
"playCount": "재생 횟수",
|
||||
"size": "크기",
|
||||
"name": "이름",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"date": "녹음 날짜",
|
||||
"originalDate": "오리지널",
|
||||
"releaseDate": "발매일",
|
||||
"releases": "발매 음반 |||| 발매 음반들",
|
||||
"released": "발매됨",
|
||||
"updatedAt": "업데이트됨",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"createdAt": "추가된 날짜",
|
||||
"recordLabel": "레이블",
|
||||
"catalogNum": "카탈로그 번호",
|
||||
"releaseType": "유형",
|
||||
"grouping": "그룹",
|
||||
"media": "미디어",
|
||||
"mood": "분위기"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "재생",
|
||||
"playNext": "다음 재생",
|
||||
"addToQueue": "나중에 재생",
|
||||
"share": "공유",
|
||||
"shuffle": "셔플",
|
||||
"addToPlaylist": "재생목록 추가",
|
||||
"download": "다운로드",
|
||||
"info": "정보 얻기"
|
||||
},
|
||||
"lists": {
|
||||
"all": "모두",
|
||||
"random": "랜덤",
|
||||
"recentlyAdded": "최근 추가됨",
|
||||
"recentlyPlayed": "최근 재생됨",
|
||||
"mostPlayed": "가장 많이 재생됨",
|
||||
"starred": "즐겨찾기",
|
||||
"topRated": "높은 평가"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"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": "ListenBrainz가 성공적으로 연결되었고 스크로블링이 사용자로 활성화되었음: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz를 연결할 수 없음: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz가 연결 해제되었고 스크로블링이 비활성화되었음",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz를 연결 해제할 수 없음",
|
||||
"downloadOriginalFormat": "오리지널 형식으로 다운로드",
|
||||
"shareOriginalFormat": "오리지널 형식으로 공유",
|
||||
"shareDialogTitle": "%{resource} '%{name}' 공유",
|
||||
"shareBatchDialogTitle": "1 %{resource} 공유 |||| %{smart_count} %{resource} 공유",
|
||||
"shareSuccess": "URL이 클립보드에 복사됨: %{url}",
|
||||
"shareFailure": "%{url}을 클립보드에 복사하는 중 오류 발생",
|
||||
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
|
||||
"shareCopyToClipboard": "클립보드에 복사: Ctrl+C, Enter"
|
||||
"artist": {
|
||||
"name": "아티스트 |||| 아티스트들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"albumCount": "앨범 수",
|
||||
"songCount": "노래 수",
|
||||
"size": "크기",
|
||||
"playCount": "재생 횟수",
|
||||
"rating": "평가",
|
||||
"genre": "장르",
|
||||
"role": "역할"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "앨범 아티스트 |||| 앨범 아티스트들",
|
||||
"artist": "아티스트 |||| 아티스트들",
|
||||
"composer": "작곡가 |||| 작곡가들",
|
||||
"conductor": "지휘자 |||| 지휘자들",
|
||||
"lyricist": "작사가 |||| 작사가들",
|
||||
"arranger": "편곡가 |||| 편곡가들",
|
||||
"producer": "제작자 |||| 제작자들",
|
||||
"director": "감독 |||| 감독들",
|
||||
"engineer": "엔지니어 |||| 엔지니어들",
|
||||
"mixer": "믹서 |||| 믹서들",
|
||||
"remixer": "리믹서 |||| 리믹서들",
|
||||
"djmixer": "DJ 믹서 |||| DJ 믹서들",
|
||||
"performer": "공연자 |||| 공연자들"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
"settings": "설정",
|
||||
"version": "버전",
|
||||
"theme": "테마",
|
||||
"personal": {
|
||||
"name": "개인 설정",
|
||||
"options": {
|
||||
"theme": "테마",
|
||||
"language": "언어",
|
||||
"defaultView": "기본 보기",
|
||||
"desktop_notifications": "데스크톱 알림",
|
||||
"lastfmScrobbling": "Last.fm으로 스크로블",
|
||||
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
|
||||
"replaygain": "리플레이게인 모드",
|
||||
"preAmp": "리플레이게인 프리앰프 (dB)",
|
||||
"gain": {
|
||||
"none": "비활성화",
|
||||
"album": "앨범 게인 사용",
|
||||
"track": "트랙 게인 사용"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "앨범",
|
||||
"about": "정보",
|
||||
"playlists": "재생목록",
|
||||
"sharedPlaylists": "공유된 재생목록"
|
||||
"user": {
|
||||
"name": "사용자 |||| 사용자들",
|
||||
"fields": {
|
||||
"userName": "사용자이름",
|
||||
"isAdmin": "관리자",
|
||||
"lastLoginAt": "마지막 로그인",
|
||||
"lastAccessAt": "마지막 접속",
|
||||
"updatedAt": "업데이트됨",
|
||||
"name": "이름",
|
||||
"password": "비밀번호",
|
||||
"createdAt": "생성됨",
|
||||
"changePassword": "비밀번호를 변경할까요?",
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"newPassword": "새 비밀번호",
|
||||
"token": "토큰"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "이름 변경 사항은 다음 로그인 시에만 반영됨"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "사용자 생성됨",
|
||||
"updated": "사용자 업데이트됨",
|
||||
"deleted": "사용자 삭제됨"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "대기열 재생",
|
||||
"openText": "열기",
|
||||
"closeText": "닫기",
|
||||
"notContentText": "음악 없음",
|
||||
"clickToPlayText": "재생하려면 클릭",
|
||||
"clickToPauseText": "일시 중지하려면 클릭",
|
||||
"nextTrackText": "다음 트랙",
|
||||
"previousTrackText": "이전 트랙",
|
||||
"reloadText": "다시 로드하기",
|
||||
"volumeText": "볼륨",
|
||||
"toggleLyricText": "가사 전환",
|
||||
"toggleMiniModeText": "최소화",
|
||||
"destroyText": "제거",
|
||||
"downloadText": "다운로드",
|
||||
"removeAudioListsText": "오디오 목록 삭제",
|
||||
"clickToDeleteText": "%{name}을(를) 삭제하려면 클릭",
|
||||
"emptyLyricText": "가사 없음",
|
||||
"playModeText": {
|
||||
"order": "순서대로",
|
||||
"orderLoop": "반복",
|
||||
"singleLoop": "노래 하나 반복",
|
||||
"shufflePlay": "셔플"
|
||||
}
|
||||
"name": "플레이어 |||| 플레이어들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"transcodingId": "트랜스코딩",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"client": "클라이언트",
|
||||
"userName": "사용자이름",
|
||||
"lastSeen": "마지막으로 봤음",
|
||||
"reportRealPath": "실제 경로 보고서",
|
||||
"scrobbleEnabled": "외부 서비스에 스크로블 보내기"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "홈페이지",
|
||||
"source": "소스 코드",
|
||||
"featureRequests": "기능 요청"
|
||||
}
|
||||
"transcoding": {
|
||||
"name": "트랜스코딩 |||| 트랜스코딩",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"targetFormat": "대상 형식",
|
||||
"defaultBitRate": "기본 비트레이트",
|
||||
"command": "명령"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "활동",
|
||||
"totalScanned": "스캔된 전체 폴더",
|
||||
"quickScan": "빠른 스캔",
|
||||
"fullScan": "전체 스캔",
|
||||
"serverUptime": "서버 가동 시간",
|
||||
"serverDown": "오프라인"
|
||||
"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": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 단축키",
|
||||
"hotkeys": {
|
||||
"show_help": "이 도움말 표시",
|
||||
"toggle_menu": "메뉴 사이드바 전환",
|
||||
"toggle_play": "재생 / 일시 중지",
|
||||
"prev_song": "이전 노래",
|
||||
"next_song": "다음 노래",
|
||||
"vol_up": "볼륨 높이기",
|
||||
"vol_down": "볼륨 낮추기",
|
||||
"toggle_love": "이 트랙을 즐겨찾기에 추가",
|
||||
"current_song": "현재 노래로 이동"
|
||||
}
|
||||
"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": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "누락 파일 |||| 누락 파일들",
|
||||
"empty": "누락 파일 없음",
|
||||
"fields": {
|
||||
"path": "경로",
|
||||
"size": "크기",
|
||||
"updatedAt": "사라짐"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "제거"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "누락된 파일이 제거되었음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Navidrome을 설치해 주셔서 감사합니다!",
|
||||
"welcome2": "관리자를 만들고 시작해 보세요",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"buttonCreateAdmin": "관리자 만들기",
|
||||
"auth_check_error": "계속하려면 로그인하세요",
|
||||
"user_menu": "프로파일",
|
||||
"username": "사용자이름",
|
||||
"password": "비밀번호",
|
||||
"sign_in": "가입",
|
||||
"sign_in_error": "인증에 실패했습니다. 다시 시도하세요",
|
||||
"logout": "로그아웃",
|
||||
"insightsCollectionNote": "Navidrome은 프로젝트 개선을 위해 익명의 사용 데이터를\n수집합니다. 자세한 내용을 알아보고 원치 않으시면 [여기]를\n클릭하여 수신 거부하세요"
|
||||
},
|
||||
"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 item selected |||| %{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": "이 사용자와 해당 사용자의 모든 데이터(재생 목록 및 환경 설정 포함)를 삭제할까요?",
|
||||
"remove_missing_title": "누락된 파일들 제거",
|
||||
"remove_missing_content": "선택한 누락된 파일을 데이터베이스에서 삭제할까요? 삭제하면 재생 횟수 및 평점을 포함하여 해당 파일에 대한 모든 참조가 영구적으로 삭제됩니다.",
|
||||
"notifications_blocked": "브라우저 설정에서 이 사이트의 알림을 차단하였음",
|
||||
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하고 있지 않음",
|
||||
"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, Enter",
|
||||
"shareSuccess": "URL이 클립보드에 복사되었음: %{url}",
|
||||
"shareFailure": "URL %{url}을 클립보드에 복사하는 중 오류가 발생하였음",
|
||||
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
|
||||
"downloadOriginalFormat": "오리지널 형식으로 다운로드"
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
"settings": "설정",
|
||||
"version": "버전",
|
||||
"theme": "테마",
|
||||
"personal": {
|
||||
"name": "개인 설정",
|
||||
"options": {
|
||||
"theme": "테마",
|
||||
"language": "언어",
|
||||
"defaultView": "기본 보기",
|
||||
"desktop_notifications": "데스크톱 알림",
|
||||
"lastfmNotConfigured": "Last.fm API 키가 구성되지 않았음",
|
||||
"lastfmScrobbling": "Last.fm으로 스크로블",
|
||||
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
|
||||
"replaygain": "리플레이게인 모드",
|
||||
"preAmp": "리플레이게인 프리앰프 (dB)",
|
||||
"gain": {
|
||||
"none": "비활성화",
|
||||
"album": "앨범 게인 사용",
|
||||
"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": "기능 요청",
|
||||
"lastInsightsCollection": "마지막 인사이트 컬렉션",
|
||||
"insights": {
|
||||
"disabled": "비활성화",
|
||||
"waiting": "대기중"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "이 트랙을 즐겨찾기에 추가"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
517
resources/i18n/no.json
Normal file
517
resources/i18n/no.json
Normal file
@@ -0,0 +1,517 @@
|
||||
{
|
||||
"languageName": "Norsk",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Sang |||| Sanger",
|
||||
"fields": {
|
||||
"albumArtist": "Album Artist",
|
||||
"duration": "Tid",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Avspillinger",
|
||||
"title": "Tittel",
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"path": "Filsti",
|
||||
"genre": "Sjanger",
|
||||
"compilation": "Samlingg",
|
||||
"year": "År",
|
||||
"size": "Filstørrelse",
|
||||
"updatedAt": "Oppdatert",
|
||||
"bitRate": "Bit rate",
|
||||
"bitDepth": "Bit depth",
|
||||
"channels": "Kanaler",
|
||||
"discSubtitle": "Disk Undertittel",
|
||||
"starred": "Favoritt",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Rangering",
|
||||
"quality": "Kvalitet",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Sist Avspilt",
|
||||
"createdAt": "Lagt til",
|
||||
"grouping": "Gruppering",
|
||||
"mood": "Stemning",
|
||||
"participants": "Ytterlige deltakere",
|
||||
"tags": "Ytterlige Tags",
|
||||
"mappedTags": "Kartlagte tags",
|
||||
"rawTags": "Rå tags"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Avspill senere",
|
||||
"playNow": "Avspill nå",
|
||||
"addToPlaylist": "Legg til i spilleliste",
|
||||
"shuffleAll": "Shuffle Alle",
|
||||
"download": "Last ned",
|
||||
"playNext": "Avspill neste",
|
||||
"info": "Få Info"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album |||| Album",
|
||||
"fields": {
|
||||
"albumArtist": "Album Artist",
|
||||
"artist": "Artist",
|
||||
"duration": "Tid",
|
||||
"songCount": "Sanger",
|
||||
"playCount": "Avspillinger",
|
||||
"size": "Størrelse",
|
||||
"name": "Navn",
|
||||
"genre": "Sjanger",
|
||||
"compilation": "Samling",
|
||||
"year": "År",
|
||||
"date": "Inspillingsdato",
|
||||
"originalDate": "Original",
|
||||
"releaseDate": "Utgitt",
|
||||
"releases": "Utgivelse |||| Utgivelser",
|
||||
"released": "Utgitt",
|
||||
"updatedAt": "Oppdatert",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Rangering",
|
||||
"createdAt": "Lagt Til",
|
||||
"recordLabel": "Plateselskap",
|
||||
"catalogNum": "Katalognummer",
|
||||
"releaseType": "Type",
|
||||
"grouping": "Gruppering",
|
||||
"media": "Media",
|
||||
"mood": "Stemning"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Avspill",
|
||||
"playNext": "Avspill Neste",
|
||||
"addToQueue": "Avspill Senere",
|
||||
"share": "Del",
|
||||
"shuffle": "Shuffle",
|
||||
"addToPlaylist": "Legg til i spilleliste",
|
||||
"download": "Last ned",
|
||||
"info": "Få Info"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alle",
|
||||
"random": "Tilfeldig",
|
||||
"recentlyAdded": "Nylig lagt til",
|
||||
"recentlyPlayed": "Nylig Avspilt",
|
||||
"mostPlayed": "Mest Avspilt",
|
||||
"starred": "Favoritter",
|
||||
"topRated": "Top Rangert"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artist |||| Artister",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"albumCount": "Album Antall",
|
||||
"songCount": "Song Antall",
|
||||
"size": "Størrelse",
|
||||
"playCount": "Avspillinger",
|
||||
"rating": "Rangering",
|
||||
"genre": "Sjanger",
|
||||
"role": "Rolle"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Album Artist |||| Album Artister",
|
||||
"artist": "Artist |||| Artister",
|
||||
"composer": "Composer |||| Composers",
|
||||
"conductor": "Conductor |||| Conductors",
|
||||
"lyricist": "Lyriker |||| Lyriker",
|
||||
"arranger": "Arranger |||| Arrangers",
|
||||
"producer": "Produsent |||| Produsenter",
|
||||
"director": "Director |||| Directors",
|
||||
"engineer": "Engineer |||| Engineers",
|
||||
"mixer": "Mixer |||| Mixers",
|
||||
"remixer": "Remixer |||| Remixers",
|
||||
"djmixer": "DJ Mixer |||| DJ Mixers",
|
||||
"performer": "Performer |||| Performers"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Bruker |||| Brukere",
|
||||
"fields": {
|
||||
"userName": "Brukernavn",
|
||||
"isAdmin": "Admin",
|
||||
"lastLoginAt": "Sist Pålogging",
|
||||
"lastAccessAt": "Sist Tilgang",
|
||||
"updatedAt": "Oppdatert",
|
||||
"name": "Navn",
|
||||
"password": "Passord",
|
||||
"createdAt": "Opprettet",
|
||||
"changePassword": "Bytt Passord?",
|
||||
"currentPassword": "Nåværende Passord",
|
||||
"newPassword": "Nytt Passord",
|
||||
"token": "Token"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Navnendringer vil ikke være synlig før neste pålogging"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Bruker opprettet",
|
||||
"updated": "Bruker oppdatert",
|
||||
"deleted": "Bruker slettet"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Fyll inn din ListenBrainz bruker token.",
|
||||
"clickHereForToken": "Klikk her for å hente din token"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Musikkavspiller |||| Musikkavspillere",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"transcodingId": "Transkoding",
|
||||
"maxBitRate": "Maks. Bit Rate",
|
||||
"client": "Klient",
|
||||
"userName": "Brukernavn",
|
||||
"lastSeen": "Sist sett",
|
||||
"reportRealPath": "Rapporter ekte filsti",
|
||||
"scrobbleEnabled": "Send Scrobbles til eksterne tjenester"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transkoding |||| Transkodinger",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"targetFormat": "Mål Format",
|
||||
"defaultBitRate": "Default Bit Rate",
|
||||
"command": "Kommando"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Spilleliste |||| Spillelister",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"duration": "Lengde",
|
||||
"ownerName": "Eier",
|
||||
"public": "Offentlig",
|
||||
"updatedAt": "Oppdatert",
|
||||
"createdAt": "Opprettet",
|
||||
"songCount": "Sanger",
|
||||
"comment": "Kommentar",
|
||||
"sync": "Auto-importer",
|
||||
"path": "Importer fra"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Velg en spilleliste:",
|
||||
"addNewPlaylist": "Opprett \"%{name}\"",
|
||||
"export": "Eksporter",
|
||||
"makePublic": "Gjør Offentlig",
|
||||
"makePrivate": "Gjør Privat"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Legg til Duplikater",
|
||||
"song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Radio |||| Radio",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"streamUrl": "Stream URL",
|
||||
"homePageUrl": "Hjemmeside URL",
|
||||
"updatedAt": "Oppdatert",
|
||||
"createdAt": "Opprettet"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Avspill"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Del |||| Delinger",
|
||||
"fields": {
|
||||
"username": "Delt Av",
|
||||
"url": "URL",
|
||||
"description": "Beskrivelse",
|
||||
"downloadable": "Tillat Nedlastinger?",
|
||||
"contents": "Innhold",
|
||||
"expiresAt": "Utløper",
|
||||
"lastVisitedAt": "Sist Besøkt",
|
||||
"visitCount": "Visninger",
|
||||
"format": "Format",
|
||||
"maxBitRate": "Maks. Bit Rate",
|
||||
"updatedAt": "Oppdatert",
|
||||
"createdAt": "Opprettet"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Manglende Fil|||| Manglende Filer",
|
||||
"empty": "Ingen Manglende Filer",
|
||||
"fields": {
|
||||
"path": "Filsti",
|
||||
"size": "Størrelse",
|
||||
"updatedAt": "Ble borte"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Fjern"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Manglende fil(er) fjernet"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Takk for at du installerte Navidrome!",
|
||||
"welcome2": "La oss begynne med å lage en admin bruker.",
|
||||
"confirmPassword": "Bekreft Passord",
|
||||
"buttonCreateAdmin": "Opprett Admin",
|
||||
"auth_check_error": "Logg inn for å fortsette",
|
||||
"user_menu": "Profil",
|
||||
"username": "Brukernavn",
|
||||
"password": "Passord",
|
||||
"sign_in": "Logg inn",
|
||||
"sign_in_error": "Autentiseringsfeil, vennligst prøv igjen",
|
||||
"logout": "Logg ut",
|
||||
"insightsCollectionNote": "Navidrome innhenter anonymisert forbruksdata\nfor å hjelpe og forbedre prosjektet.\nTrykk [her] for å lære mer og for å melde deg av hvis ønskelig."
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Det er kun bokstaver og tall som støttes",
|
||||
"passwordDoesNotMatch": "Passord samstemmer ikke",
|
||||
"required": "Kreves",
|
||||
"minLength": "Må være minst %{min} karakterer.",
|
||||
"maxLength": "Må være %{max} karakterer eller mindre",
|
||||
"minValue": "Må være minst %{min}",
|
||||
"maxValue": "Må være %{max} eller mindre",
|
||||
"number": "Må være et tall",
|
||||
"email": "Må være en gyldig epost",
|
||||
"oneOf": "Må være en av: %{options}",
|
||||
"regex": "Må samstemme med et spesifikt format (regexp): %{pattern}",
|
||||
"unique": "Må være unikt",
|
||||
"url": "Må være en gyldig URL"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Legg til filter",
|
||||
"add": "Legg Til",
|
||||
"back": "Tilbake",
|
||||
"bulk_actions": "1 element valgt |||| %{smart_count} elementer valgt",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Avbryt",
|
||||
"clear_input_value": "Nullstill verdi",
|
||||
"clone": "Klone",
|
||||
"confirm": "Bekreft",
|
||||
"create": "Opprett",
|
||||
"delete": "Slett",
|
||||
"edit": "Rediger",
|
||||
"export": "Eksporter",
|
||||
"list": "Liste",
|
||||
"refresh": "Oppdater",
|
||||
"remove_filter": "Fjern dette filteret",
|
||||
"remove": "Fjern",
|
||||
"save": "Lagre",
|
||||
"search": "Søk",
|
||||
"show": "Vis",
|
||||
"sort": "Sorter",
|
||||
"undo": "Angre",
|
||||
"expand": "Utvid",
|
||||
"close": "Lukk",
|
||||
"open_menu": "Åpne meny",
|
||||
"close_menu": "Lukk meny",
|
||||
"unselect": "Avvelg",
|
||||
"skip": "Hopp over",
|
||||
"share": "Del",
|
||||
"download": "Last Ned"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ja",
|
||||
"false": "Nei"
|
||||
},
|
||||
"page": {
|
||||
"create": "Opprett %{name}",
|
||||
"dashboard": "Dashboard",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Noe gikk galt",
|
||||
"list": "%{name}",
|
||||
"loading": "Laster",
|
||||
"not_found": "Ikke Funnet",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Ingen %{name} enda.",
|
||||
"invite": "Ønsker du å legge til en?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Dra filer hit for å laste opp, eller klikk for å velge en.",
|
||||
"upload_single": "Dra en fil hit for å laste opp, eller klikk for å velge den."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Dra bilder hit for å laste opp, eller klikk for å velge en.",
|
||||
"upload_single": "Dra et bilde hit for å laste opp, eller klikk for å velge den."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Finner ikke referansedata.",
|
||||
"many_missing": "Minst en av de tilhørende referansene ser ikke lenger ut til å være tilgjengelig.",
|
||||
"single_missing": "Tilhørende referanse ser ikke lenger ut til å være tilgjengelig."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Skjul passord",
|
||||
"toggle_hidden": "Vis passord"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Om",
|
||||
"are_you_sure": "Er du sikker?",
|
||||
"bulk_delete_content": "Er du sikker på at du vil slette denne %{name}? |||| Er du sikker på at du vil slette disse %{smart_count} elementene?",
|
||||
"bulk_delete_title": "Slett %{name} |||| Slett %{smart_count} %{name}",
|
||||
"delete_content": "Er du sikker på at du ønsker å slette dette elementet?",
|
||||
"delete_title": "Slett %{name} #%{id}",
|
||||
"details": "Detaljer",
|
||||
"error": "En klient feil har oppstått og din forespørsel lot seg ikke gjennomføre.",
|
||||
"invalid_form": "Skjemaet er ikke gyldig. Vennligst se etter feil.",
|
||||
"loading": "Siden laster, vennligst vent.",
|
||||
"no": "Nei",
|
||||
"not_found": "Enten skrev du feil URL, eller så har du fulgt en dårlig link.",
|
||||
"yes": "Ja",
|
||||
"unsaved_changes": "Noen av dine endringer ble ikke lagret. Er du sikker på at du ønsker å ignorere de?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Ingen resultater",
|
||||
"no_more_results": "Sidenummeret %{page} er utenfor grensene. Prøv forrige side.",
|
||||
"page_out_of_boundaries": "Sidenummer %{page} er utenfor grensene",
|
||||
"page_out_from_end": "Kan ikke være etter siste side",
|
||||
"page_out_from_begin": "Kan ikke være før side 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
|
||||
"page_rows_per_page": "Elementer per side:",
|
||||
"next": "Neste",
|
||||
"prev": "Forrige",
|
||||
"skip_nav": "Hopp til innhold"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Element oppdatert |||| %{smart_count} elementer oppdatert",
|
||||
"created": "Element opprettet",
|
||||
"deleted": "Element slettet |||| %{smart_count} elementer slettet",
|
||||
"bad_item": "Feil element",
|
||||
"item_doesnt_exist": "Element eksisterer ikke",
|
||||
"http_error": "Kommunikasjonsfeil mot server",
|
||||
"data_provider_error": "dataProvider feil. Sjekk konsollet for feil.",
|
||||
"i18n_error": "Klarte ikke laste oversettelser for valgt språk.",
|
||||
"canceled": "Handling avbrutt",
|
||||
"logged_out": "Din sesjon er avsluttet, vennligst koble til på nytt.",
|
||||
"new_version": "Ny versjon tilgjengelig! Vennligst last siden på nytt."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Vis følgende kolonner",
|
||||
"layout": "Layout",
|
||||
"grid": "Rutenett",
|
||||
"table": "Tabell"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "NOTAT",
|
||||
"transcodingDisabled": "Endringer på transkodingkonfigurasjon fra web grensesnittet er deaktivert grunnet sikkerhet. Hvis du ønsker å endre eller legge til transkodingsmuligheter, restart serveren med %{config} konfigurasjonsalternativ.",
|
||||
"transcodingEnabled": "Navidrome kjører for øyeblikket med %{config}, som gjør det mulig å kjøre systemkommandoer fra transkodingsinstillinger i web grensesnittet. Vi anbefaler å deaktivere denne muligheten av sikkerhetsårsaker og heller kun ha det aktivert under konfigurasjon av transkodingsmuligheter.",
|
||||
"songsAddedToPlaylist": "Lagt til 1 sang i spillelisten |||| Lagt til %{smart_count} sanger i spillelisten",
|
||||
"noPlaylistsAvailable": "Ingen tilgjengelig",
|
||||
"delete_user_title": "Slett bruker '%{name}'",
|
||||
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og all tilhørlig data (inkludert spillelister og preferanser)?",
|
||||
"remove_missing_title": "Fjern manglende filer",
|
||||
"remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
|
||||
"notifications_blocked": "Du har blokkert notifikasjoner for denne nettsiden i din nettleser.",
|
||||
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsnotifikasjoner, eller så er du ikke tilkoblet Navidrome via https.",
|
||||
"lastfmLinkSuccess": "Last.fm er tilkoblet og scrobbling er aktivert",
|
||||
"lastfmLinkFailure": "Last.fm kunne ikke koble til",
|
||||
"lastfmUnlinkSuccess": "Last.fm er avkoblet og scrobbling er deaktivert",
|
||||
"lastfmUnlinkFailure": "Last.fm kunne ikke avkobles",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
|
||||
"openIn": {
|
||||
"lastfm": "Åpne i Last.fm",
|
||||
"musicbrainz": "Åpne i MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Les Mer...",
|
||||
"shareOriginalFormat": "Del i originalformat",
|
||||
"shareDialogTitle": "Del %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
|
||||
"shareSuccess": "URL kopiert til utklippstavle: %{url}",
|
||||
"shareFailure": "Error ved kopiering av URL %{url} til utklippstavle",
|
||||
"downloadDialogTitle": "Last ned %{resource} '%{name}' (%{size})",
|
||||
"downloadOriginalFormat": "Last ned i originalformat"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
"settings": "Instillinger",
|
||||
"version": "Versjon",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
"name": "Personlig",
|
||||
"options": {
|
||||
"theme": "Tema",
|
||||
"language": "Språk",
|
||||
"defaultView": "Standardvisning",
|
||||
"desktop_notifications": "Skrivebordsnotifikasjoner",
|
||||
"lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert",
|
||||
"lastfmScrobbling": "Scrobble til Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobble til ListenBrainz",
|
||||
"replaygain": "ReplayGain Mode",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"gain": {
|
||||
"none": "Deaktivert",
|
||||
"album": "Bruk Album Gain",
|
||||
"track": "Bruk Track Gain"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Album",
|
||||
"playlists": "Spillelister",
|
||||
"sharedPlaylists": "Delte Spillelister",
|
||||
"about": "Om"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Spill Av Kø",
|
||||
"openText": "Åpne",
|
||||
"closeText": "Lukk",
|
||||
"notContentText": "Ingen musikk",
|
||||
"clickToPlayText": "Klikk for å avspille",
|
||||
"clickToPauseText": "Klikk for å pause",
|
||||
"nextTrackText": "Neste spor",
|
||||
"previousTrackText": "Forrige spor",
|
||||
"reloadText": "Last på nytt",
|
||||
"volumeText": "Volum",
|
||||
"toggleLyricText": "Slå på/av sangtekster",
|
||||
"toggleMiniModeText": "Minimer",
|
||||
"destroyText": "Ødelegg",
|
||||
"downloadText": "Last Ned",
|
||||
"removeAudioListsText": "Slett lydlister",
|
||||
"clickToDeleteText": "Klikk for å slette %{name}",
|
||||
"emptyLyricText": "Ingen sangtekster",
|
||||
"playModeText": {
|
||||
"order": "I rekkefølge",
|
||||
"orderLoop": "Repeat",
|
||||
"singleLoop": "Repeat En",
|
||||
"shufflePlay": "Shuffle"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Hjemmeside",
|
||||
"source": "Kildekode",
|
||||
"featureRequests": "Funksjonsforespørseler",
|
||||
"lastInsightsCollection": "Siste Innsamling av anonymisert forbruksdata",
|
||||
"insights": {
|
||||
"disabled": "Deaktivert",
|
||||
"waiting": "Venter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivitet",
|
||||
"totalScanned": "Antall mapper skannet",
|
||||
"quickScan": "Hurtigskann",
|
||||
"fullScan": "Full Skann",
|
||||
"serverUptime": "Server Oppetid",
|
||||
"serverDown": "OFFLINE"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Hurtigtaster",
|
||||
"hotkeys": {
|
||||
"show_help": "Vis Hjelp",
|
||||
"toggle_menu": "Åpne/Lukke Sidepanel",
|
||||
"toggle_play": "Avspill / Pause",
|
||||
"prev_song": "Forrige Sang",
|
||||
"next_song": "Neste Sang",
|
||||
"current_song": "Gå til Nåværende Sang",
|
||||
"vol_up": "Volum Opp",
|
||||
"vol_down": "Volum Ned",
|
||||
"toggle_love": "Legg til spor i favoritter"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,14 @@
|
||||
"bpm": "BPM",
|
||||
"playDate": "Ostatnio Odtwarzane",
|
||||
"channels": "Kanały",
|
||||
"createdAt": "Data dodania"
|
||||
"createdAt": "Data dodania",
|
||||
"grouping": "Grupowanie",
|
||||
"mood": "Nastrój",
|
||||
"participants": "Dodatkowi uczestnicy",
|
||||
"tags": "Dodatkowe Tagi",
|
||||
"mappedTags": "Zmapowane tagi",
|
||||
"rawTags": "Surowe tagi",
|
||||
"bitDepth": "Głębokość próbkowania"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Odtwarzaj Później",
|
||||
@@ -58,7 +65,14 @@
|
||||
"originalDate": "Pierwotna Data",
|
||||
"releaseDate": "Data Wydania",
|
||||
"releases": "Wydanie |||| Wydania",
|
||||
"released": "Wydany"
|
||||
"released": "Wydany",
|
||||
"recordLabel": "Wytwórnia",
|
||||
"catalogNum": "Numer Katalogowy",
|
||||
"releaseType": "Typ",
|
||||
"grouping": "Grupowanie",
|
||||
"media": "Media",
|
||||
"mood": "Nastrój",
|
||||
"date": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Odtwarzaj",
|
||||
@@ -89,7 +103,23 @@
|
||||
"playCount": "Liczba Odtworzeń",
|
||||
"rating": "Ocena",
|
||||
"genre": "Gatunek",
|
||||
"size": "Rozmiar"
|
||||
"size": "Rozmiar",
|
||||
"role": "Rola"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Wykonawca Albumu |||| Wykonawcy Albumu",
|
||||
"artist": "Wykonawca |||| Wykonawcy",
|
||||
"composer": "Kompozytor |||| Kompozytorzy",
|
||||
"conductor": "Dyrygent |||| Dyrygenci",
|
||||
"lyricist": "Autor tekstów |||| Autorzy tekstów",
|
||||
"arranger": "Aranżer |||| Aranżerzy",
|
||||
"producer": "Producent |||| Producenci",
|
||||
"director": "Reżyser |||| Reżyserzy",
|
||||
"engineer": "Inżynier |||| Inżynierowie",
|
||||
"mixer": "Mikser |||| Mikserzy",
|
||||
"remixer": "Remixer |||| Remixerzy",
|
||||
"djmixer": "Didżej |||| Didżerzy",
|
||||
"performer": "Wykonawca |||| Wykonawcy"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -198,6 +228,21 @@
|
||||
"createdAt": "Stworzono",
|
||||
"downloadable": "Zezwolić Na Pobieranie?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Brakujący Plik|||| Brakujące Pliki",
|
||||
"fields": {
|
||||
"path": "Ścieżka",
|
||||
"size": "Rozmiar",
|
||||
"updatedAt": "Zniknął na"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Usuń"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Usunięto brakujące pliki"
|
||||
},
|
||||
"empty": "Bez Brakujących Plików"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -375,7 +420,9 @@
|
||||
"shareSuccess": "Adres URL skopiowany do schowka: %{url}",
|
||||
"shareFailure": "Błąd podczas kopiowania URL %{url} do schowka",
|
||||
"downloadDialogTitle": "Pobierz %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Skopiuj do schowka: Ctrl+C, Enter"
|
||||
"shareCopyToClipboard": "Skopiuj do schowka: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Usuń brakujące dane",
|
||||
"remove_missing_content": "Czy na pewno chcesz usunąć wybrane brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszystkich powiązań, takich jak liczba odtworzeń i oceny."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteka",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"size": "Tamanho",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"bitRate": "Bitrate",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"discSubtitle": "Sub-título do disco",
|
||||
"starred": "Favorita",
|
||||
"comment": "Comentário",
|
||||
@@ -56,6 +57,7 @@
|
||||
"genre": "Gênero",
|
||||
"compilation": "Coletânea",
|
||||
"year": "Ano",
|
||||
"date": "Data de Lançamento",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"comment": "Comentário",
|
||||
"rating": "Classificação",
|
||||
@@ -229,6 +231,7 @@
|
||||
},
|
||||
"missing": {
|
||||
"name": "Arquivo ausente |||| Arquivos ausentes",
|
||||
"empty": "Nenhum arquivo ausente",
|
||||
"fields": {
|
||||
"path": "Caminho",
|
||||
"size": "Tamanho",
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
"participants": "Дополнительные участники",
|
||||
"tags": "Дополнительные теги",
|
||||
"mappedTags": "Сопоставленные теги",
|
||||
"rawTags": "Исходные теги"
|
||||
"rawTags": "Исходные теги",
|
||||
"bitDepth": "Битовая глубина",
|
||||
"sampleRate": "Частота дискретизации (Гц)"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "В очередь",
|
||||
@@ -70,7 +72,8 @@
|
||||
"releaseType": "Тип",
|
||||
"grouping": "Группирование",
|
||||
"media": "Медиа",
|
||||
"mood": "Настроение"
|
||||
"mood": "Настроение",
|
||||
"date": "Дата записи"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Играть",
|
||||
@@ -239,7 +242,8 @@
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Отсутствующие файлы удалены"
|
||||
}
|
||||
},
|
||||
"empty": "Нет отсутствующих файлов"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
|
||||
@@ -1,465 +1,517 @@
|
||||
{
|
||||
"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": "Последња пријава",
|
||||
"lastAccessAt": "Последњи приступ",
|
||||
"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": {
|
||||
}
|
||||
}
|
||||
"languageName": "српски",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Песма |||| Песме",
|
||||
"fields": {
|
||||
"album": "Албум",
|
||||
"albumArtist": "Уметник албума",
|
||||
"artist": "Уметник",
|
||||
"bitDepth": "Битова",
|
||||
"bitRate": "Битски проток",
|
||||
"bpm": "BPM",
|
||||
"channels": "Канала",
|
||||
"comment": "Коментар",
|
||||
"compilation": "Компилација",
|
||||
"createdAt": "Датум додавања",
|
||||
"discSubtitle": "Поднаслов диска",
|
||||
"duration": "Трајање",
|
||||
"genre": "Жанр",
|
||||
"grouping": "Груписање",
|
||||
"mappedTags": "Мапиране ознаке",
|
||||
"mood": "Расположење",
|
||||
"participants": "Додатни учесници",
|
||||
"path": "Путања фајла",
|
||||
"playCount": "Пуштано",
|
||||
"playDate": "Последње пуштано",
|
||||
"quality": "Квалитет",
|
||||
"rating": "Рејтинг",
|
||||
"rawTags": "Сирове ознаке",
|
||||
"size": "Величина фајла",
|
||||
"starred": "Омиљено",
|
||||
"tags": "Додатне ознаке",
|
||||
"title": "Наслов",
|
||||
"trackNumber": "#",
|
||||
"updatedAt": "Ажурирано",
|
||||
"year": "Година"
|
||||
},
|
||||
"actions": {
|
||||
"addToPlaylist": "Додај у плејлисту",
|
||||
"addToQueue": "Пусти касније",
|
||||
"download": "Преузми",
|
||||
"info": "Прикажи инфо",
|
||||
"playNext": "Пусти наредно",
|
||||
"playNow": "Пусти одмах",
|
||||
"shuffleAll": "Измешај све"
|
||||
}
|
||||
},
|
||||
"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": "Табела"
|
||||
}
|
||||
"album": {
|
||||
"name": "Албум |||| Албуми",
|
||||
"fields": {
|
||||
"albumArtist": "Уметник албума",
|
||||
"artist": "Уметник",
|
||||
"catalogNum": "Каталошки број",
|
||||
"comment": "Коментар",
|
||||
"compilation": "Компилација",
|
||||
"createdAt": "Датум додавања",
|
||||
"date": "Датум снимања",
|
||||
"duration": "Трајање",
|
||||
"genre": "Жанр",
|
||||
"grouping": "Груписање",
|
||||
"media": "Медијум",
|
||||
"mood": "Расположење",
|
||||
"name": "Назив",
|
||||
"originalDate": "Оригинално",
|
||||
"playCount": "Пуштано",
|
||||
"rating": "Рејтинг",
|
||||
"recordLabel": "Издавачка кућа",
|
||||
"releaseDate": "Објављено",
|
||||
"releaseType": "Тип",
|
||||
"released": "Објављено",
|
||||
"releases": "Издање|||| Издања",
|
||||
"size": "Величина",
|
||||
"songCount": "Песме",
|
||||
"updatedAt": "Ажурирано",
|
||||
"year": "Година"
|
||||
},
|
||||
"actions": {
|
||||
"addToPlaylist": "Додај у плејлисту",
|
||||
"addToQueue": "Пусти касније",
|
||||
"download": "Преузми",
|
||||
"info": "Прикажи инфо",
|
||||
"playAll": "Пусти",
|
||||
"playNext": "Пусти наредно",
|
||||
"share": "Дели",
|
||||
"shuffle": "Измешај"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Све",
|
||||
"mostPlayed": "Најчешће пуштано",
|
||||
"random": "Насумично",
|
||||
"recentlyAdded": "Додато недавно",
|
||||
"recentlyPlayed": "Пуштано недавно",
|
||||
"starred": "Омиљено",
|
||||
"topRated": "Најбоље рангирано"
|
||||
}
|
||||
},
|
||||
"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": "Преузми у оригиналном формату"
|
||||
"artist": {
|
||||
"name": "Уметник |||| Уметници",
|
||||
"fields": {
|
||||
"albumCount": "Број албума",
|
||||
"genre": "Жанр",
|
||||
"name": "Назив",
|
||||
"playCount": "Пуштано",
|
||||
"rating": "Рејтинг",
|
||||
"role": "Улога",
|
||||
"size": "Величина",
|
||||
"songCount": "Број песама"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Уметник албума |||| Уметници албума",
|
||||
"arranger": "Аранжер |||| Аранжери",
|
||||
"artist": "Уметник |||| Уметници",
|
||||
"composer": "Композитор |||| Композитори",
|
||||
"conductor": "Диригент |||| Диригенти",
|
||||
"director": "Режисер |||| Режисери",
|
||||
"djmixer": "Ди-џеј миксер |||| Ди-џеј миксер",
|
||||
"engineer": "Инжењер |||| Инжењери",
|
||||
"lyricist": "Текстописац |||| Текстописци",
|
||||
"mixer": "Миксер |||| Миксери",
|
||||
"performer": "Извођач |||| Извођачи",
|
||||
"producer": "Продуцент |||| Продуценти",
|
||||
"remixer": "Ремиксер |||| Ремиксери"
|
||||
}
|
||||
},
|
||||
"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": "Искључено",
|
||||
"album": "Користи Album појачање",
|
||||
"track": "Користи Track појачање"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Албуми",
|
||||
"playlists": "Плејлисте",
|
||||
"sharedPlaylists": "Дељене плејлисте",
|
||||
"about": "О"
|
||||
"user": {
|
||||
"name": "Корисник |||| Корисници",
|
||||
"fields": {
|
||||
"changePassword": "Измени лозинку?",
|
||||
"createdAt": "Креирана",
|
||||
"currentPassword": "Текућа лозинка",
|
||||
"isAdmin": "Да ли је Админ",
|
||||
"lastAccessAt": "Последњи приступ",
|
||||
"lastLoginAt": "Последња пријава",
|
||||
"name": "Назив",
|
||||
"newPassword": "Нова лозинка",
|
||||
"password": "Лозинка",
|
||||
"token": "Жетон",
|
||||
"updatedAt": "Ажурирано",
|
||||
"userName": "Корисничко име"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Измене вашег имена ће постати видљиве након следеће пријаве"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Корисник креиран",
|
||||
"deleted": "Корисник обрисан",
|
||||
"updated": "Корисник ажуриран"
|
||||
},
|
||||
"message": {
|
||||
"clickHereForToken": "Кликните овде да преузмете свој жетон",
|
||||
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон."
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Ред за пуштање",
|
||||
"openText": "Отвори",
|
||||
"closeText": "Затвори",
|
||||
"notContentText": "Нема музике",
|
||||
"clickToPlayText": "Кликни за пуштање",
|
||||
"clickToPauseText": "Кликни за паузирање",
|
||||
"nextTrackText": "Наредна нумера",
|
||||
"previousTrackText": "Претходна нумера",
|
||||
"reloadText": "Поново учитај",
|
||||
"volumeText": "Јачина",
|
||||
"toggleLyricText": "Укљ./Искљ. стихове",
|
||||
"toggleMiniModeText": "Умањи",
|
||||
"destroyText": "Уништи",
|
||||
"downloadText": "Преузми",
|
||||
"removeAudioListsText": "Обриши аудио листе",
|
||||
"clickToDeleteText": "Кликните да обришете %{name}",
|
||||
"emptyLyricText": "Нема стихова",
|
||||
"playModeText": {
|
||||
"order": "По редоследу",
|
||||
"orderLoop": "Понови",
|
||||
"singleLoop": "Понови једну",
|
||||
"shufflePlay": "Промешано"
|
||||
}
|
||||
"name": "Плејер |||| Плејери",
|
||||
"fields": {
|
||||
"client": "Клијент",
|
||||
"lastSeen": "Последњи пут виђен",
|
||||
"maxBitRate": "Макс. битски проток",
|
||||
"name": "Назив",
|
||||
"reportRealPath": "Пријављуј реалну путању",
|
||||
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе",
|
||||
"transcodingId": "Транскодирање",
|
||||
"userName": "Корисничко име"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Почетна страница",
|
||||
"source": "Изворни кôд",
|
||||
"featureRequests": "Захтеви за функцијама"
|
||||
}
|
||||
"transcoding": {
|
||||
"name": "Транскодирање |||| Транскодирања",
|
||||
"fields": {
|
||||
"command": "Команда",
|
||||
"defaultBitRate": "Подразумевани битски проток",
|
||||
"name": "Назив",
|
||||
"targetFormat": "Циљни формат"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Активност",
|
||||
"totalScanned": "Укупан број скенираних фолдера",
|
||||
"quickScan": "Брзо скенирање",
|
||||
"fullScan": "Комплетно скенирање",
|
||||
"serverUptime": "Сервер се извршава",
|
||||
"serverDown": "ВАН МРЕЖЕ"
|
||||
"playlist": {
|
||||
"name": "Плејлиста |||| Плејлисте",
|
||||
"fields": {
|
||||
"comment": "Коментар",
|
||||
"createdAt": "Креирана",
|
||||
"duration": "Трајање",
|
||||
"name": "Назив",
|
||||
"ownerName": "Власник",
|
||||
"path": "Увоз из",
|
||||
"public": "Јавна",
|
||||
"songCount": "Песме",
|
||||
"sync": "Ауто-увоз",
|
||||
"updatedAt": "Ажурирано"
|
||||
},
|
||||
"actions": {
|
||||
"addNewPlaylist": "Креирај „%{name}”",
|
||||
"export": "Извези",
|
||||
"makePrivate": "Учини приватном",
|
||||
"makePublic": "Учини јавном",
|
||||
"selectPlaylist": "Изабери плејлисту"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Додај дуплиране песме",
|
||||
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome пречице",
|
||||
"hotkeys": {
|
||||
"show_help": "Прикажи ову помоћ",
|
||||
"toggle_menu": "Укљ./Искљ. бочну траку менија",
|
||||
"toggle_play": "Пусти / Паузирај",
|
||||
"prev_song": "Претходна песма",
|
||||
"next_song": "Наредна песма",
|
||||
"current_song": "Иди на текућу песму",
|
||||
"vol_up": "Појачај",
|
||||
"vol_down": "Утишај",
|
||||
"toggle_love": "Додај ову нумеру у омиљене"
|
||||
}
|
||||
"radio": {
|
||||
"name": "Радио |||| Радији",
|
||||
"fields": {
|
||||
"createdAt": "Креирана",
|
||||
"homePageUrl": "URL почетне странице",
|
||||
"name": "Назив",
|
||||
"streamUrl": "URL тока",
|
||||
"updatedAt": "Ажурирано"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Пусти одмах"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Дељење |||| Дељења",
|
||||
"fields": {
|
||||
"contents": "Садржај",
|
||||
"createdAt": "Креирано",
|
||||
"description": "Опис",
|
||||
"downloadable": "Допушта се преузимање?",
|
||||
"expiresAt": "Истиче",
|
||||
"format": "Формат",
|
||||
"lastVisitedAt": "Последњи пут посећено",
|
||||
"maxBitRate": "Макс. битски проток",
|
||||
"updatedAt": "Ажурирано",
|
||||
"url": "URL",
|
||||
"username": "Поделио",
|
||||
"visitCount": "Број посета"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Фајл који недостаје|||| Фајлови који недостају",
|
||||
"empty": "Нема фајлова који недостају",
|
||||
"fields": {
|
||||
"path": "Путања",
|
||||
"size": "Величина",
|
||||
"updatedAt": "Нестао дана"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Уклони"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Фајл који недостаје, или више њих, је уклоњен"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
|
||||
"buttonCreateAdmin": "Креирај админа",
|
||||
"confirmPassword": "Потврдите лозинку",
|
||||
"insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите",
|
||||
"logout": "Одјави се",
|
||||
"password": "Лозинка",
|
||||
"sign_in": "Пријави се",
|
||||
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
|
||||
"user_menu": "Профил",
|
||||
"username": "Корисничко име",
|
||||
"welcome1": "Хвала што сте инсталирали Navidrome!",
|
||||
"welcome2": "За почетак, креирајте админ корисника"
|
||||
},
|
||||
"validation": {
|
||||
"email": "Мора да буде исправна и-мејл адреса",
|
||||
"invalidChars": "Молимо вас да користите само слова и цифре",
|
||||
"maxLength": "Мора да буде %{max} карактера или мање",
|
||||
"maxValue": "Мора да буде %{max} или мање",
|
||||
"minLength": "Мора да буде барем %{min} карактера",
|
||||
"minValue": "Мора да буде барем %{min}",
|
||||
"number": "Мора да буде број",
|
||||
"oneOf": "Мора да буде једно од: %{options}",
|
||||
"passwordDoesNotMatch": "Лозинка се не подудара",
|
||||
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
|
||||
"required": "Неопходно",
|
||||
"unique": "Мора да буде јединствено",
|
||||
"url": "Мора да буде исправна URL адреса"
|
||||
},
|
||||
"action": {
|
||||
"add": "Додај",
|
||||
"add_filter": "Додај филтер",
|
||||
"back": "Иди назад",
|
||||
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Откажи",
|
||||
"clear_input_value": "Обриши вредност",
|
||||
"clone": "Клонирај",
|
||||
"close": "Затвори",
|
||||
"close_menu": "Затвори мени",
|
||||
"confirm": "Потврди",
|
||||
"create": "Креирај",
|
||||
"delete": "Обриши",
|
||||
"download": "Преузми",
|
||||
"edit": "Уреди",
|
||||
"expand": "Развиј",
|
||||
"export": "Извези",
|
||||
"list": "Листа",
|
||||
"open_menu": "Отвори мени",
|
||||
"refresh": "Освежи",
|
||||
"remove": "Уклони",
|
||||
"remove_filter": "Уклони овај филтер",
|
||||
"save": "Сачувај",
|
||||
"search": "Тражи",
|
||||
"share": "Дели",
|
||||
"show": "Прикажи",
|
||||
"skip": "Прескочи",
|
||||
"sort": "Сортирај",
|
||||
"undo": "Поништи",
|
||||
"unselect": "Уклони избор"
|
||||
},
|
||||
"boolean": {
|
||||
"false": "Не",
|
||||
"true": "Да"
|
||||
},
|
||||
"page": {
|
||||
"create": "Креирај %{name}",
|
||||
"dashboard": "Контролна табла",
|
||||
"edit": "%{name} #%{id}",
|
||||
"empty": "Још увек нема %{name}.",
|
||||
"error": "Нешто је пошло наопако",
|
||||
"invite": "Желите ли да се дода?",
|
||||
"list": "%{name}",
|
||||
"loading": "Учитава се",
|
||||
"not_found": "Није пронађено",
|
||||
"show": "%{name} #%{id}"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
|
||||
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
|
||||
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
|
||||
},
|
||||
"password": {
|
||||
"toggle_hidden": "Прикажи лозинку",
|
||||
"toggle_visible": "Сакриј лозинку"
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Не могу да се нађу подаци референци.",
|
||||
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
|
||||
"single_missing": "Изгледа да придружена референца више није доступна."
|
||||
}
|
||||
},
|
||||
"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 адресу, или сте следили неисправан линк.",
|
||||
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?",
|
||||
"yes": "Да"
|
||||
},
|
||||
"navigation": {
|
||||
"next": "Наредна",
|
||||
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
|
||||
"no_results": "Није пронађен ниједан резултат",
|
||||
"page_out_from_begin": "Не може да се иде испред странице 1",
|
||||
"page_out_from_end": "Не може да се иде након последње странице",
|
||||
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
|
||||
"page_rows_per_page": "Ставки по страници:",
|
||||
"prev": "Претход",
|
||||
"skip_nav": "Прескочи на садржај"
|
||||
},
|
||||
"notification": {
|
||||
"bad_item": "Неисправни елемент",
|
||||
"canceled": "Акција је отказана",
|
||||
"created": "Елемент је креиран",
|
||||
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
|
||||
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
|
||||
"http_error": "Грешка у комуникацији са сервером",
|
||||
"i18n_error": "Не могу да се учитају преводи за наведени језик",
|
||||
"item_doesnt_exist": "Елемент не постоји",
|
||||
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
|
||||
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.",
|
||||
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано"
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Колоне за приказ",
|
||||
"grid": "Мрежа",
|
||||
"layout": "Распоред",
|
||||
"table": "Табела"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
|
||||
"delete_user_title": "Брисање корисника ’%{name}’",
|
||||
"downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})",
|
||||
"downloadOriginalFormat": "Преузми у оригиналном формату",
|
||||
"lastfmLink": "Прочитај још...",
|
||||
"lastfmLinkFailure": "Last.fm није могао да се повеже",
|
||||
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
|
||||
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
|
||||
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
|
||||
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
|
||||
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
|
||||
"noPlaylistsAvailable": "Није доступна ниједна",
|
||||
"note": "НАПОМЕНА",
|
||||
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
|
||||
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
|
||||
"openIn": {
|
||||
"lastfm": "Отвори у Last.fm",
|
||||
"musicbrainz": "Отвори у MusicBrainz"
|
||||
},
|
||||
"remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.",
|
||||
"remove_missing_title": "Уклони фајлове који недостају",
|
||||
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
|
||||
"shareDialogTitle": "Подели %{resource} ’%{name}’",
|
||||
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
|
||||
"shareOriginalFormat": "Подели у оригиналном формату",
|
||||
"shareSuccess": "URL је копиран у клипборд: %{url}",
|
||||
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
|
||||
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
|
||||
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања."
|
||||
},
|
||||
"menu": {
|
||||
"about": "О",
|
||||
"albumList": "Албуми",
|
||||
"library": "Библиотека",
|
||||
"personal": {
|
||||
"name": "Лична",
|
||||
"options": {
|
||||
"defaultView": "Подразумевани поглед",
|
||||
"desktop_notifications": "Десктоп обавештења",
|
||||
"gain": {
|
||||
"album": "Користи Album појачање",
|
||||
"none": "Искључено",
|
||||
"track": "Користи Track појачање"
|
||||
},
|
||||
"language": "Језик",
|
||||
"lastfmNotConfigured": "Није подешен Last.fm API-кључ",
|
||||
"lastfmScrobbling": "Скроблуј на Last.fm",
|
||||
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
|
||||
"preAmp": "ReplayGain претпојачање (dB)",
|
||||
"replaygain": "ReplayGain режим",
|
||||
"theme": "Тема"
|
||||
}
|
||||
},
|
||||
"playlists": "Плејлисте",
|
||||
"settings": "Подешавања",
|
||||
"sharedPlaylists": "Дељене плејлисте",
|
||||
"theme": "Тема",
|
||||
"version": "Верзија"
|
||||
},
|
||||
"player": {
|
||||
"clickToDeleteText": "Кликните да обришете %{name}",
|
||||
"clickToPauseText": "Кликни за паузирање",
|
||||
"clickToPlayText": "Кликни за пуштање",
|
||||
"closeText": "Затвори",
|
||||
"destroyText": "Уништи",
|
||||
"downloadText": "Преузми",
|
||||
"emptyLyricText": "Нема стихова",
|
||||
"nextTrackText": "Наредна нумера",
|
||||
"notContentText": "Нема музике",
|
||||
"openText": "Отвори",
|
||||
"playListsText": "Ред за пуштање",
|
||||
"playModeText": {
|
||||
"order": "По редоследу",
|
||||
"orderLoop": "Понови",
|
||||
"shufflePlay": "Измешај",
|
||||
"singleLoop": "Понови једну"
|
||||
},
|
||||
"previousTrackText": "Претходна нумера",
|
||||
"reloadText": "Поново учитај",
|
||||
"removeAudioListsText": "Обриши аудио листе",
|
||||
"toggleLyricText": "Укљ./Искљ. стихове",
|
||||
"toggleMiniModeText": "Умањи",
|
||||
"volumeText": "Јачина"
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"featureRequests": "Захтеви за функцијама",
|
||||
"homepage": "Почетна страница",
|
||||
"insights": {
|
||||
"disabled": "Искључено",
|
||||
"waiting": "Чека се"
|
||||
},
|
||||
"lastInsightsCollection": "Последња колекција увида",
|
||||
"source": "Изворни кôд"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"fullScan": "Комплетно скенирање",
|
||||
"quickScan": "Брзо скенирање",
|
||||
"serverDown": "ВАН МРЕЖЕ",
|
||||
"serverUptime": "Сервер се извршава",
|
||||
"title": "Активност",
|
||||
"totalScanned": "Укупан број скенираних фолдера"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome пречице",
|
||||
"hotkeys": {
|
||||
"current_song": "Иди на текућу песму",
|
||||
"next_song": "Наредна песма",
|
||||
"prev_song": "Претходна песма",
|
||||
"show_help": "Прикажи ову помоћ",
|
||||
"toggle_love": "Додај ову нумеру у омиљене",
|
||||
"toggle_menu": "Укљ./Искљ. бочну траку менија",
|
||||
"toggle_play": "Пусти / Паузирај",
|
||||
"vol_down": "Утишај",
|
||||
"vol_up": "Појачај"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
"participants": "Ek katılımcılar",
|
||||
"tags": "Ek Etiketler",
|
||||
"mappedTags": "Eşlenen etiketler",
|
||||
"rawTags": "Ham etiketler"
|
||||
"rawTags": "Ham etiketler",
|
||||
"bitDepth": "Bit derinliği",
|
||||
"sampleRate": "Örnekleme Oranı"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Oynatma Sırasına Ekle",
|
||||
@@ -70,7 +72,8 @@
|
||||
"releaseType": "Tür",
|
||||
"grouping": "Gruplama",
|
||||
"media": "Medya",
|
||||
"mood": "Mod"
|
||||
"mood": "Mod",
|
||||
"date": "Kayıt Tarihi"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Oynat",
|
||||
@@ -239,7 +242,8 @@
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Eksik dosya(lar) kaldırıldı"
|
||||
}
|
||||
},
|
||||
"empty": "Eksik Dosya Yok"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
"artist": "歌手",
|
||||
"album": "专辑",
|
||||
"path": "文件路径",
|
||||
"genre": "类型",
|
||||
"genre": "流派",
|
||||
"compilation": "合辑",
|
||||
"year": "发行年份",
|
||||
"size": "文件大小",
|
||||
"updatedAt": "更新于",
|
||||
"bitRate": "比特率",
|
||||
"bitDepth": "比特深度",
|
||||
"channels": "声道",
|
||||
"discSubtitle": "字幕",
|
||||
"starred": "收藏",
|
||||
"comment": "注释",
|
||||
@@ -25,8 +27,13 @@
|
||||
"quality": "品质",
|
||||
"bpm": "BPM",
|
||||
"playDate": "最后一次播放",
|
||||
"channels": "声道",
|
||||
"createdAt": "创建于"
|
||||
"createdAt": "创建于",
|
||||
"grouping": "分组",
|
||||
"mood": "情绪",
|
||||
"participants": "其他参与人员",
|
||||
"tags": "附加标签",
|
||||
"mappedTags": "映射标签",
|
||||
"rawTags": "原始标签"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "加入播放列表",
|
||||
@@ -46,29 +53,36 @@
|
||||
"duration": "时长",
|
||||
"songCount": "歌曲数量",
|
||||
"playCount": "播放次数",
|
||||
"size": "文件大小",
|
||||
"name": "名称",
|
||||
"genre": "类型",
|
||||
"genre": "流派",
|
||||
"compilation": "合辑",
|
||||
"year": "发行年份",
|
||||
"date": "录制日期",
|
||||
"originalDate": "原始日期",
|
||||
"releaseDate": "发⾏日期",
|
||||
"releases": "发⾏",
|
||||
"released": "已发⾏",
|
||||
"updatedAt": "更新于",
|
||||
"comment": "注释",
|
||||
"rating": "评分",
|
||||
"createdAt": "创建于",
|
||||
"size": "文件大小",
|
||||
"originalDate": "原始日期",
|
||||
"releaseDate": "发⾏日期",
|
||||
"releases": "发⾏",
|
||||
"released": "已发⾏"
|
||||
"recordLabel": "厂牌",
|
||||
"catalogNum": "目录编号",
|
||||
"releaseType": "发行类型",
|
||||
"grouping": "分组",
|
||||
"media": "媒体类型",
|
||||
"mood": "情绪"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "立即播放",
|
||||
"playNext": "下首播放",
|
||||
"addToQueue": "加入播放列表",
|
||||
"share": "分享",
|
||||
"shuffle": "随机播放",
|
||||
"addToPlaylist": "加入歌单",
|
||||
"download": "下载",
|
||||
"info": "查看信息",
|
||||
"share": "分享"
|
||||
"info": "查看信息"
|
||||
},
|
||||
"lists": {
|
||||
"all": "所有",
|
||||
@@ -86,10 +100,26 @@
|
||||
"name": "名称",
|
||||
"albumCount": "专辑数",
|
||||
"songCount": "歌曲数",
|
||||
"size": "文件大小",
|
||||
"playCount": "播放次数",
|
||||
"rating": "评分",
|
||||
"genre": "类型",
|
||||
"size": "文件大小"
|
||||
"genre": "流派",
|
||||
"role": "参与角色"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "专辑歌手",
|
||||
"artist": "歌手",
|
||||
"composer": "作曲",
|
||||
"conductor": "指挥",
|
||||
"lyricist": "作词",
|
||||
"arranger": "编曲",
|
||||
"producer": "制作人",
|
||||
"director": "总监",
|
||||
"engineer": "工程师",
|
||||
"mixer": "混音师",
|
||||
"remixer": "重混师",
|
||||
"djmixer": "DJ混音师",
|
||||
"performer": "演奏家"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -98,6 +128,7 @@
|
||||
"userName": "用户名",
|
||||
"isAdmin": "是否管理员",
|
||||
"lastLoginAt": "上次登录",
|
||||
"lastAccessAt": "上次访问",
|
||||
"updatedAt": "更新于",
|
||||
"name": "名称",
|
||||
"password": "密码",
|
||||
@@ -108,7 +139,7 @@
|
||||
"token": "令牌"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "你名字的更改将在下次登录生效"
|
||||
"name": "名称的更改将在下次登录时生效"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "用户已创建",
|
||||
@@ -187,6 +218,7 @@
|
||||
"username": "分享者",
|
||||
"url": "链接",
|
||||
"description": "描述",
|
||||
"downloadable": "是否允许下载?",
|
||||
"contents": "目录",
|
||||
"expiresAt": "过期于",
|
||||
"lastVisitedAt": "上次访问于",
|
||||
@@ -194,8 +226,24 @@
|
||||
"format": "格式",
|
||||
"maxBitRate": "最大比特率",
|
||||
"updatedAt": "更新于",
|
||||
"createdAt": "创建于",
|
||||
"downloadable": "是否允许下载"
|
||||
"createdAt": "创建于"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "丢失文件",
|
||||
"empty": "无丢失文件",
|
||||
"fields": {
|
||||
"path": "路径",
|
||||
"size": "文件大小",
|
||||
"updatedAt": "丢失于"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "移除"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "丢失文件已移除"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,7 +259,8 @@
|
||||
"password": "密码",
|
||||
"sign_in": "登录",
|
||||
"sign_in_error": "验证失败,请重试",
|
||||
"logout": "注销"
|
||||
"logout": "注销",
|
||||
"insightsCollectionNote": "Navidrome 会收集匿名使用数据以协助改进项目。\n点击[此处]了解详情或选择退出。"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "请使用字母和数字",
|
||||
@@ -233,6 +282,7 @@
|
||||
"add": "添加",
|
||||
"back": "返回",
|
||||
"bulk_actions": "选中 %{smart_count} 项",
|
||||
"bulk_actions_mobile": "%{smart_count}",
|
||||
"cancel": "取消",
|
||||
"clear_input_value": "清除",
|
||||
"clone": "复制",
|
||||
@@ -256,7 +306,6 @@
|
||||
"close_menu": "关闭菜单",
|
||||
"unselect": "未选择",
|
||||
"skip": "跳过",
|
||||
"bulk_actions_mobile": "%{smart_count}",
|
||||
"share": "分享",
|
||||
"download": "下载"
|
||||
},
|
||||
@@ -351,29 +400,31 @@
|
||||
"noPlaylistsAvailable": "没有有效的歌单",
|
||||
"delete_user_title": "删除用户 %{name}",
|
||||
"delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?",
|
||||
"remove_missing_title": "移除丢失文件",
|
||||
"remove_missing_content": "您确定要将选中的丢失文件从数据库中永久移除吗?此操作将删除所有相关信息,包括播放次数和评分。",
|
||||
"notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知",
|
||||
"notifications_not_available": "此浏览器不支持桌面通知",
|
||||
"lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录",
|
||||
"lastfmLinkFailure": "Last.fm 无法关联",
|
||||
"lastfmUnlinkSuccess": "已成功解除与 Last.fm 的链接,且喜好记录已禁用",
|
||||
"lastfmUnlinkFailure": "Last.fm 无法取消关联",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录",
|
||||
"listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}",
|
||||
"listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联",
|
||||
"openIn": {
|
||||
"lastfm": "在 Last.fm 中打开",
|
||||
"musicbrainz": "在 MusicBrainz 中打开"
|
||||
},
|
||||
"lastfmLink": "查看更多…",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录",
|
||||
"listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}",
|
||||
"listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联",
|
||||
"downloadOriginalFormat": "下载原始格式",
|
||||
"shareOriginalFormat": "分享原始格式",
|
||||
"shareDialogTitle": "分享 %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "分享 %{smart_count} 个 %{resource}",
|
||||
"shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter",
|
||||
"shareSuccess": "分享链接已复制: %{url}",
|
||||
"shareFailure": "分享链接复制失败: %{url}",
|
||||
"downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter"
|
||||
"downloadOriginalFormat": "下载原始格式"
|
||||
},
|
||||
"menu": {
|
||||
"library": "曲库",
|
||||
@@ -387,6 +438,7 @@
|
||||
"language": "语言",
|
||||
"defaultView": "默认界面",
|
||||
"desktop_notifications": "桌面通知",
|
||||
"lastfmNotConfigured": "没有配置 Last.fm 的 API-Key",
|
||||
"lastfmScrobbling": "启用 Last.fm 的喜好记录",
|
||||
"listenBrainzScrobbling": "启用 ListenBrainz 的喜好记录",
|
||||
"replaygain": "回放增益",
|
||||
@@ -399,9 +451,9 @@
|
||||
}
|
||||
},
|
||||
"albumList": "专辑",
|
||||
"about": "关于",
|
||||
"playlists": "歌单",
|
||||
"sharedPlaylists": "共享的歌单"
|
||||
"sharedPlaylists": "共享的歌单",
|
||||
"about": "关于"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "播放列表",
|
||||
@@ -432,7 +484,12 @@
|
||||
"links": {
|
||||
"homepage": "主页",
|
||||
"source": "源代码",
|
||||
"featureRequests": "功能需求"
|
||||
"featureRequests": "功能需求",
|
||||
"lastInsightsCollection": " 最近的分析收集",
|
||||
"insights": {
|
||||
"disabled": "禁用",
|
||||
"waiting": "等待"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -451,10 +508,10 @@
|
||||
"toggle_play": "播放/暂停",
|
||||
"prev_song": "上一首歌",
|
||||
"next_song": "下一首歌",
|
||||
"current_song": "转到当前播放",
|
||||
"vol_up": "增大音量",
|
||||
"vol_down": "减小音量",
|
||||
"toggle_love": "添加/移除星标",
|
||||
"current_song": "转到当前播放"
|
||||
"toggle_love": "添加/移除星标"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
#file: noinspection SpellCheckingInspection
|
||||
# Tag mapping adapted from https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
|
||||
#
|
||||
# NOTE FOR USERS:
|
||||
#
|
||||
# This file can be used as a reference to understand how Navidrome maps the tags in your music files to its fields.
|
||||
# If you want to customize these mappings, please refer to https://www.navidrome.org/docs/usage/customtags/
|
||||
#
|
||||
#
|
||||
# NOTE FOR DEVELOPERS:
|
||||
#
|
||||
# This file contains the mapping between the tags in your music files and the fields in Navidrome.
|
||||
# You can add new tags, change the aliases, or add new split characters to the existing tags.
|
||||
# The artists and roles keys are used to define how to split the tag values into multiple values.
|
||||
@@ -96,11 +104,11 @@ main:
|
||||
aliases: [ disctotal, totaldiscs ]
|
||||
album: true
|
||||
discsubtitle:
|
||||
aliases: [ tsst, discsubtitle, ----:com.apple.itunes:discsubtitle, wm/setsubtitle ]
|
||||
aliases: [ tsst, discsubtitle, ----:com.apple.itunes:discsubtitle, setsubtitle, wm/setsubtitle ]
|
||||
bpm:
|
||||
aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ]
|
||||
lyrics:
|
||||
aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics ]
|
||||
aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics, unsyncedlyrics ]
|
||||
maxLength: 32768
|
||||
type: pair # ex: lyrics:eng, lyrics:xxx
|
||||
comment:
|
||||
@@ -110,10 +118,10 @@ main:
|
||||
aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear ]
|
||||
type: date
|
||||
recordingdate:
|
||||
aliases: [ tdrc, date, icrd, ©day, wm/year, year ]
|
||||
aliases: [ tdrc, date, recordingdate, icrd, record date ]
|
||||
type: date
|
||||
releasedate:
|
||||
aliases: [ tdrl, releasedate ]
|
||||
aliases: [ tdrl, releasedate, ©day, wm/year, year ]
|
||||
type: date
|
||||
catalognumber:
|
||||
aliases: [ txxx:catalognumber, catalognumber, ----:com.apple.itunes:catalognumber, wm/catalogno ]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 293 KiB |
466
scanner/README.md
Normal file
466
scanner/README.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# Navidrome Scanner: Technical Overview
|
||||
|
||||
This document provides a comprehensive technical explanation of Navidrome's music library scanner system.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The Navidrome scanner is built on a multi-phase pipeline architecture designed for efficient processing of music files. It systematically traverses file system directories, processes metadata, and maintains a database representation of the music library. A key performance feature is that some phases run sequentially while others execute in parallel.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "Scanner Execution Flow"
|
||||
Controller[Scanner Controller] --> Scanner[Scanner Implementation]
|
||||
|
||||
Scanner --> Phase1[Phase 1: Folders Scan]
|
||||
Phase1 --> Phase2[Phase 2: Missing Tracks]
|
||||
|
||||
Phase2 --> ParallelPhases
|
||||
|
||||
subgraph ParallelPhases["Parallel Execution"]
|
||||
Phase3[Phase 3: Refresh Albums]
|
||||
Phase4[Phase 4: Playlist Import]
|
||||
end
|
||||
|
||||
ParallelPhases --> FinalSteps[Final Steps: GC + Stats]
|
||||
end
|
||||
|
||||
%% Triggers that can initiate a scan
|
||||
FileChanges[File System Changes] -->|Detected by| Watcher[Filesystem Watcher]
|
||||
Watcher -->|Triggers| Controller
|
||||
|
||||
ScheduledJob[Scheduled Job] -->|Based on Scanner.Schedule| Controller
|
||||
ServerStartup[Server Startup] -->|If Scanner.ScanOnStartup=true| Controller
|
||||
ManualTrigger[Manual Scan via UI/API] -->|Admin user action| Controller
|
||||
CLICommand[Command Line: navidrome scan] -->|Direct invocation| Controller
|
||||
PIDChange[PID Configuration Change] -->|Forces full scan| Controller
|
||||
DBMigration[Database Migration] -->|May require full scan| Controller
|
||||
|
||||
Scanner -.->|Alternative| External[External Scanner Process]
|
||||
```
|
||||
|
||||
The execution flow shows that Phases 1 and 2 run sequentially, while Phases 3 and 4 execute in parallel to maximize performance before the final processing steps.
|
||||
|
||||
## Core Components
|
||||
|
||||
### Scanner Controller (`controller.go`)
|
||||
|
||||
This is the entry point for all scanning operations. It provides:
|
||||
|
||||
- Public API for initiating scans and checking scan status
|
||||
- Event broadcasting to notify clients about scan progress
|
||||
- Serialization of scan operations (prevents concurrent scans)
|
||||
- Progress tracking and monitoring
|
||||
- Error collection and reporting
|
||||
|
||||
```go
|
||||
type Scanner interface {
|
||||
// ScanAll starts a full scan of the music library. This is a blocking operation.
|
||||
ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
|
||||
Status(context.Context) (*StatusInfo, error)
|
||||
}
|
||||
```
|
||||
|
||||
### Scanner Implementation (`scanner.go`)
|
||||
|
||||
The primary implementation that orchestrates the four-phase scanning pipeline. Each phase follows the Phase interface pattern:
|
||||
|
||||
```go
|
||||
type phase[T any] interface {
|
||||
producer() ppl.Producer[T]
|
||||
stages() []ppl.Stage[T]
|
||||
finalize(error) error
|
||||
description() string
|
||||
}
|
||||
```
|
||||
|
||||
This design enables:
|
||||
- Type-safe pipeline construction with generics
|
||||
- Modular phase implementation
|
||||
- Separation of concerns
|
||||
- Easy measurement of performance
|
||||
|
||||
### External Scanner (`external.go`)
|
||||
|
||||
The External Scanner is a specialized implementation that offloads the scanning process to a separate subprocess. This is specifically designed to address memory management challenges in long-running Navidrome instances.
|
||||
|
||||
```go
|
||||
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
|
||||
// memory leaks or retention in the main process, as the scanner can consume a lot of memory. The
|
||||
// external process will be spawned with the same executable as the current process, and will run
|
||||
// the "scan" command with the "--subprocess" flag.
|
||||
//
|
||||
// The external process will send progress updates to the main process through its STDOUT, and the main
|
||||
// process will forward them to the caller.
|
||||
```
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MP as Main Process
|
||||
participant ES as External Scanner
|
||||
participant SP as Subprocess (navidrome scan --subprocess)
|
||||
participant FS as File System
|
||||
participant DB as Database
|
||||
|
||||
Note over MP: DevExternalScanner=true
|
||||
MP->>ES: ScanAll(ctx, fullScan)
|
||||
activate ES
|
||||
|
||||
ES->>ES: Locate executable path
|
||||
ES->>SP: Start subprocess with args:<br>scan --subprocess --configfile ... etc.
|
||||
activate SP
|
||||
|
||||
Note over ES,SP: Create pipe for communication
|
||||
|
||||
par Subprocess executes scan
|
||||
SP->>FS: Read files & metadata
|
||||
SP->>DB: Update database
|
||||
and Main process monitors progress
|
||||
loop For each progress update
|
||||
SP->>ES: Send encoded progress info via stdout pipe
|
||||
ES->>MP: Forward progress info
|
||||
end
|
||||
end
|
||||
|
||||
SP-->>ES: Subprocess completes (success/error)
|
||||
deactivate SP
|
||||
ES-->>MP: Return aggregated warnings/errors
|
||||
deactivate ES
|
||||
```
|
||||
|
||||
Technical details:
|
||||
|
||||
1. **Process Isolation**
|
||||
- Spawns a separate process using the same executable
|
||||
- Uses the `--subprocess` flag to indicate it's running as a child process
|
||||
- Preserves configuration by passing required flags (`--configfile`, `--datafolder`, etc.)
|
||||
|
||||
2. **Inter-Process Communication**
|
||||
- Uses a pipe for bidirectional communication
|
||||
- Encodes/decodes progress updates using Go's `gob` encoding for efficient binary transfer
|
||||
- Properly handles process termination and error propagation
|
||||
|
||||
3. **Memory Management Benefits**
|
||||
- Scanning operations can be memory-intensive, especially with large music libraries
|
||||
- Memory leaks or excessive allocations are automatically cleaned up when the process terminates
|
||||
- Main Navidrome process remains stable even if scanner encounters memory-related issues
|
||||
|
||||
4. **Error Handling**
|
||||
- Detects non-zero exit codes from the subprocess
|
||||
- Propagates error messages back to the main process
|
||||
- Ensures resources are properly cleaned up, even in error conditions
|
||||
|
||||
## Scanning Process Flow
|
||||
|
||||
### Phase 1: Folder Scan (`phase_1_folders.go`)
|
||||
|
||||
This phase handles the initial traversal and media file processing.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 1] --> B{Full Scan?}
|
||||
B -- Yes --> C[Scan All Folders]
|
||||
B -- No --> D[Scan Modified Folders]
|
||||
C --> E[Read File Metadata]
|
||||
D --> E
|
||||
E --> F[Create Artists]
|
||||
E --> G[Create Albums]
|
||||
F --> H[Save to Database]
|
||||
G --> H
|
||||
H --> I[Mark Missing Folders]
|
||||
I --> J[End Phase 1]
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Folder Traversal**
|
||||
- Uses `walkDirTree` to traverse the directory structure
|
||||
- Handles symbolic links and hidden files
|
||||
- Processes `.ndignore` files for exclusions
|
||||
- Maps files to appropriate types (audio, image, playlist)
|
||||
|
||||
2. **Metadata Extraction**
|
||||
- Processes files in batches (defined by `filesBatchSize = 200`)
|
||||
- Extracts metadata using the configured storage backend
|
||||
- Converts raw metadata to `MediaFile` objects
|
||||
- Collects and normalizes tag information
|
||||
|
||||
3. **Album and Artist Creation**
|
||||
- Groups tracks by album ID
|
||||
- Creates album records from track metadata
|
||||
- Handles album ID changes by tracking previous IDs
|
||||
- Creates artist records from track participants
|
||||
|
||||
4. **Database Persistence**
|
||||
- Uses transactions for atomic updates
|
||||
- Preserves album annotations across ID changes
|
||||
- Updates library-artist mappings
|
||||
- Marks missing tracks for later processing
|
||||
- Pre-caches artwork for performance
|
||||
|
||||
### Phase 2: Missing Tracks Processing (`phase_2_missing_tracks.go`)
|
||||
|
||||
This phase identifies tracks that have moved or been deleted.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 2] --> B[Load Libraries]
|
||||
B --> C[Get Missing and Matching Tracks]
|
||||
C --> D[Group by PID]
|
||||
D --> E{Match Type?}
|
||||
E -- Exact --> F[Update Path]
|
||||
E -- Same PID --> G[Update If Only One]
|
||||
E -- Equivalent --> H[Update If No Better Match]
|
||||
F --> I[End Phase 2]
|
||||
G --> I
|
||||
H --> I
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Track Identification Strategy**
|
||||
- Uses persistent identifiers (PIDs) to track tracks across scans
|
||||
- Loads missing tracks and potential matches from the database
|
||||
- Groups tracks by PID to limit comparison scope
|
||||
|
||||
2. **Match Analysis**
|
||||
- Applies three levels of matching criteria:
|
||||
- Exact match (full metadata equivalence)
|
||||
- Single match for a PID
|
||||
- Equivalent match (same base path or similar metadata)
|
||||
- Prioritizes matches in order of confidence
|
||||
|
||||
3. **Database Update Strategy**
|
||||
- Preserves the original track ID
|
||||
- Updates the path to the new location
|
||||
- Deletes the duplicate entry
|
||||
- Uses transactions to ensure atomicity
|
||||
|
||||
### Phase 3: Album Refresh (`phase_3_refresh_albums.go`)
|
||||
|
||||
This phase updates album information based on the latest track metadata.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 3] --> B[Load Touched Albums]
|
||||
B --> C[Filter Unmodified]
|
||||
C --> D{Changes Detected?}
|
||||
D -- Yes --> E[Refresh Album Data]
|
||||
D -- No --> F[Skip]
|
||||
E --> G[Update Database]
|
||||
F --> H[End Phase 3]
|
||||
G --> H
|
||||
H --> I[Refresh Statistics]
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Album Selection Logic**
|
||||
- Loads albums that have been "touched" in previous phases
|
||||
- Uses a producer-consumer pattern for efficient processing
|
||||
- Retrieves all media files for each album for completeness
|
||||
|
||||
2. **Change Detection**
|
||||
- Rebuilds album metadata from associated tracks
|
||||
- Compares album attributes for changes
|
||||
- Skips albums with no media files
|
||||
- Avoids unnecessary database updates
|
||||
|
||||
3. **Statistics Refreshing**
|
||||
- Updates album play counts
|
||||
- Updates artist play counts
|
||||
- Maintains consistency between related entities
|
||||
|
||||
### Phase 4: Playlist Import (`phase_4_playlists.go`)
|
||||
|
||||
This phase imports and updates playlists from the file system.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 4] --> B{AutoImportPlaylists?}
|
||||
B -- No --> C[Skip]
|
||||
B -- Yes --> D{Admin User Exists?}
|
||||
D -- No --> E[Log Warning & Skip]
|
||||
D -- Yes --> F[Load Folders with Playlists]
|
||||
F --> G{For Each Folder}
|
||||
G --> H[Read Directory]
|
||||
H --> I{For Each Playlist}
|
||||
I --> J[Import Playlist]
|
||||
J --> K[Pre-cache Artwork]
|
||||
K --> L[End Phase 4]
|
||||
C --> L
|
||||
E --> L
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Playlist Discovery**
|
||||
- Loads folders known to contain playlists
|
||||
- Focuses on folders that have been touched in previous phases
|
||||
- Handles both playlist formats (M3U, NSP)
|
||||
|
||||
2. **Import Process**
|
||||
- Uses the core.Playlists service for import
|
||||
- Handles both regular and smart playlists
|
||||
- Updates existing playlists when changed
|
||||
- Pre-caches playlist cover art
|
||||
|
||||
3. **Configuration Awareness**
|
||||
- Respects the AutoImportPlaylists setting
|
||||
- Requires an admin user for playlist import
|
||||
- Logs appropriate messages for configuration issues
|
||||
|
||||
## Final Processing Steps
|
||||
|
||||
After the four main phases, several finalization steps occur:
|
||||
|
||||
1. **Garbage Collection**
|
||||
- Removes dangling tracks with no files
|
||||
- Cleans up empty albums
|
||||
- Removes orphaned artists
|
||||
- Deletes orphaned annotations
|
||||
|
||||
2. **Statistics Refresh**
|
||||
- Updates artist song and album counts
|
||||
- Refreshes tag usage statistics
|
||||
- Updates aggregate metrics
|
||||
|
||||
3. **Library Status Update**
|
||||
- Marks scan as completed
|
||||
- Updates last scan timestamp
|
||||
- Stores persistent ID configuration
|
||||
|
||||
4. **Database Optimization**
|
||||
- Performs database maintenance
|
||||
- Optimizes tables and indexes
|
||||
- Reclaims space from deleted records
|
||||
|
||||
## File System Watching
|
||||
|
||||
The watcher system (`watcher.go`) provides real-time monitoring of file system changes:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Watcher] --> B[For Each Library]
|
||||
B --> C[Start Library Watcher]
|
||||
C --> D[Monitor File Events]
|
||||
D --> E{Change Detected?}
|
||||
E -- Yes --> F[Wait for More Changes]
|
||||
F --> G{Time Elapsed?}
|
||||
G -- Yes --> H[Trigger Scan]
|
||||
G -- No --> F
|
||||
H --> I[Wait for Scan Completion]
|
||||
I --> D
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Event Throttling**
|
||||
- Uses a timer to batch changes
|
||||
- Prevents excessive rescanning
|
||||
- Configurable wait period
|
||||
|
||||
2. **Library-specific Watching**
|
||||
- Each library has its own watcher goroutine
|
||||
- Translates paths to library-relative paths
|
||||
- Filters irrelevant changes
|
||||
|
||||
3. **Platform Adaptability**
|
||||
- Uses storage-provided watcher implementation
|
||||
- Supports different notification mechanisms per platform
|
||||
- Graceful fallback when watching is not supported
|
||||
|
||||
## Edge Cases and Optimizations
|
||||
|
||||
### Handling Album ID Changes
|
||||
|
||||
The scanner carefully manages album identity across scans:
|
||||
- Tracks previous album IDs to handle ID generation changes
|
||||
- Preserves annotations when IDs change
|
||||
- Maintains creation timestamps for consistent sorting
|
||||
|
||||
### Detecting Moved Files
|
||||
|
||||
A sophisticated algorithm identifies moved files:
|
||||
1. Groups missing and new files by their Persistent ID
|
||||
2. Applies multiple matching strategies in priority order
|
||||
3. Updates paths rather than creating duplicate entries
|
||||
|
||||
### Resuming Interrupted Scans
|
||||
|
||||
If a scan is interrupted:
|
||||
- The next scan detects this condition
|
||||
- Forces a full scan if the previous one was a full scan
|
||||
- Continues from where it left off for incremental scans
|
||||
|
||||
### Memory Efficiency
|
||||
|
||||
Several strategies minimize memory usage:
|
||||
- Batched file processing (200 files at a time)
|
||||
- External scanner process option
|
||||
- Database-side filtering where possible
|
||||
- Stream processing with pipelines
|
||||
|
||||
### Concurrency Control
|
||||
|
||||
The scanner implements a sophisticated concurrency model to optimize performance:
|
||||
|
||||
1. **Phase-Level Parallelism**:
|
||||
- Phases 1 and 2 run sequentially due to their dependencies
|
||||
- Phases 3 and 4 run in parallel using the `chain.RunParallel()` function
|
||||
- Final steps run sequentially to ensure data consistency
|
||||
|
||||
2. **Within-Phase Concurrency**:
|
||||
- Each phase has configurable concurrency for its stages
|
||||
- For example, `phase_1_folders.go` processes folders concurrently: `ppl.NewStage(p.processFolder, ppl.Name("process folder"), ppl.Concurrency(conf.Server.DevScannerThreads))`
|
||||
- Multiple stages can exist within a phase, each with its own concurrency level
|
||||
|
||||
3. **Pipeline Architecture Benefits**:
|
||||
- Producer-consumer pattern minimizes memory usage
|
||||
- Work is streamed through stages rather than accumulated
|
||||
- Back-pressure is automatically managed
|
||||
|
||||
4. **Thread Safety Mechanisms**:
|
||||
- Atomic counters for statistics gathering
|
||||
- Mutex protection for shared resources
|
||||
- Transactional database operations
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The scanner's behavior can be customized through several configuration settings that directly affect its operation:
|
||||
|
||||
### Core Scanner Options
|
||||
|
||||
| Setting | Description | Default |
|
||||
|-------------------------|------------------------------------------------------------------|----------------|
|
||||
| `Scanner.Enabled` | Whether the automatic scanner is enabled | true |
|
||||
| `Scanner.Schedule` | Cron expression or duration for scheduled scans (e.g., "@daily") | "0" (disabled) |
|
||||
| `Scanner.ScanOnStartup` | Whether to scan when the server starts | true |
|
||||
| `Scanner.WatcherWait` | Delay before triggering scan after file changes detected | 5s |
|
||||
| `Scanner.ArtistJoiner` | String used to join multiple artists in track metadata | " • " |
|
||||
|
||||
### Playlist Processing
|
||||
|
||||
| Setting | Description | Default |
|
||||
|-----------------------------|----------------------------------------------------------|---------|
|
||||
| `PlaylistsPath` | Path(s) to search for playlists (supports glob patterns) | "" |
|
||||
| `AutoImportPlaylists` | Whether to import playlists during scanning | true |
|
||||
|
||||
### Performance Options
|
||||
|
||||
| Setting | Description | Default |
|
||||
|----------------------|-----------------------------------------------------------|---------|
|
||||
| `DevExternalScanner` | Use external process for scanning (reduces memory issues) | true |
|
||||
| `DevScannerThreads` | Number of concurrent processing threads during scanning | 5 |
|
||||
|
||||
### Persistent ID Options
|
||||
|
||||
| Setting | Description | Default |
|
||||
|-------------|---------------------------------------------------------------------|---------------------------------------------------------------------|
|
||||
| `PID.Track` | Format for track persistent IDs (critical for tracking moved files) | "musicbrainz_trackid\|albumid,discnumber,tracknumber,title" |
|
||||
| `PID.Album` | Format for album persistent IDs (affects album grouping) | "musicbrainz_albumid\|albumartistid,album,albumversion,releasedate" |
|
||||
|
||||
These options can be set in the Navidrome configuration file (e.g., `navidrome.toml`) or via environment variables with the `ND_` prefix (e.g., `ND_SCANNER_ENABLED=false`). For environment variables, dots in option names are replaced with underscores.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Navidrome scanner represents a sophisticated system for efficiently managing music libraries. Its phase-based pipeline architecture, careful handling of edge cases, and performance optimizations allow it to handle libraries of significant size while maintaining data integrity and providing a responsive user experience.
|
||||
@@ -98,7 +98,6 @@ type ProgressInfo struct {
|
||||
|
||||
type scanner interface {
|
||||
scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo)
|
||||
// BFR: scanFolders(ctx context.Context, lib model.Lib, folders []string, progress chan<- *ScannerStatus)
|
||||
}
|
||||
|
||||
type controller struct {
|
||||
|
||||
@@ -33,6 +33,8 @@ func (s *scannerExternal) scanAll(ctx context.Context, fullScan bool, progress c
|
||||
cmd := exec.CommandContext(ctx, exe, "scan",
|
||||
"--nobanner", "--subprocess",
|
||||
"--configfile", conf.Server.ConfigFile,
|
||||
"--datafolder", conf.Server.DataFolder,
|
||||
"--cachefolder", conf.Server.CacheFolder,
|
||||
If(fullScan, "--full", ""))
|
||||
|
||||
in, out := io.Pipe()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user