mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
123 Commits
v0.55.0
...
fix/defaul
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb851f2a8 | ||
|
|
5e2db2c673 | ||
|
|
fac9275c27 | ||
|
|
6b3afc03cc | ||
|
|
35599230ff | ||
|
|
13ea00e7f8 | ||
|
|
f7fb77054f | ||
|
|
441c9f52cc | ||
|
|
b722f0dcfc | ||
|
|
c98e4d02cb | ||
|
|
5ade9344ff | ||
|
|
d903d3f1e0 | ||
|
|
6bf6424864 | ||
|
|
a9f93c97e1 | ||
|
|
3350e6c115 | ||
|
|
514aceb785 | ||
|
|
370f8ba293 | ||
|
|
1e4c759d93 | ||
|
|
e06fbd26b7 | ||
|
|
9062f4824e | ||
|
|
2503d2dbb8 | ||
|
|
45188e710c | ||
|
|
9dd050c377 | ||
|
|
3ccc02f375 | ||
|
|
992c78376c | ||
|
|
4a2412eef7 | ||
|
|
98fdc42d09 | ||
|
|
eb944bd261 | ||
|
|
84384006a4 | ||
|
|
e5438552c6 | ||
|
|
6ac3acaaf8 | ||
|
|
3953e3217d | ||
|
|
6731787053 | ||
|
|
dd1d3907b4 | ||
|
|
924354eb4b | ||
|
|
6880cffd16 | ||
|
|
fef1739c1a | ||
|
|
453630d430 | ||
|
|
4733616d90 | ||
|
|
ba7fd13724 | ||
|
|
1e4e3eac6e | ||
|
|
19d443ec7f | ||
|
|
db92cf9e47 | ||
|
|
ec9f9aa243 | ||
|
|
5c18951e31 | ||
|
|
2b744c878e | ||
|
|
0d1f2bcc8a | ||
|
|
dfa217ab51 | ||
|
|
3d6a2380bc | ||
|
|
4548e75d49 | ||
|
|
841af03393 | ||
|
|
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"
|
||||
|
||||
53
.github/copilot-instructions.md
vendored
Normal file
53
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
# Navidrome Code Guidelines
|
||||
|
||||
This is a music streaming server written in Go with a React frontend. The application manages music libraries, provides streaming capabilities, and offers various features like artist information, artwork handling, and external service integrations.
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (Go)
|
||||
- Follow standard Go conventions and idioms
|
||||
- Use context propagation for cancellation signals
|
||||
- Write unit tests for new functionality using Ginkgo/Gomega
|
||||
- Use mutex appropriately for concurrent operations
|
||||
- Implement interfaces for dependencies to facilitate testing
|
||||
|
||||
### Frontend (React)
|
||||
- Use functional components with hooks
|
||||
- Follow React best practices for state management
|
||||
- Implement PropTypes for component properties
|
||||
- Prefer using React-Admin and Material-UI components
|
||||
- Icons should be imported from `react-icons` only
|
||||
- Follow existing patterns for API interaction
|
||||
|
||||
## Repository Structure
|
||||
- `core/`: Server-side business logic (artwork handling, playback, etc.)
|
||||
- `ui/`: React frontend components
|
||||
- `model/`: Data models and repository interfaces
|
||||
- `server/`: API endpoints and server implementation
|
||||
- `utils/`: Shared utility functions
|
||||
- `persistence/`: Database access layer
|
||||
- `scanner/`: Music library scanning functionality
|
||||
|
||||
## Key Guidelines
|
||||
1. Maintain cache management patterns for performance
|
||||
2. Follow the existing concurrency patterns (mutex, atomic)
|
||||
3. Use the testing framework appropriately (Ginkgo/Gomega for Go)
|
||||
4. Keep UI components focused and reusable
|
||||
5. Document configuration options in code
|
||||
6. Consider performance implications when working with music libraries
|
||||
7. Follow existing error handling patterns
|
||||
8. Ensure compatibility with external services (LastFM, Spotify)
|
||||
|
||||
## Development Workflow
|
||||
- Test changes thoroughly, especially around concurrent operations
|
||||
- Validate both backend and frontend interactions
|
||||
- Consider how changes will affect user experience and performance
|
||||
- Test with different music library sizes and configurations
|
||||
- Before committing, ALWAYS run `make format lint test`, and make sure there are no issues
|
||||
|
||||
## Important commands
|
||||
- `make build`: Build the application
|
||||
- `make test`: Run Go tests
|
||||
- To run tests for a specific package, use `make test PKG=./pkgname/...`
|
||||
- `make lintall`: Run linters
|
||||
- `make format`: Format code
|
||||
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@v8
|
||||
with:
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,4 +24,6 @@ docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
binaries
|
||||
navidrome-master
|
||||
*.exe
|
||||
AGENTS.md
|
||||
*.exe
|
||||
bin/
|
||||
@@ -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$
|
||||
|
||||
@@ -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"]
|
||||
|
||||
31
Makefile
31
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
|
||||
@@ -45,11 +46,15 @@ testrace: ##@Development Run Go tests with race detector
|
||||
.PHONY: test
|
||||
|
||||
testall: testrace ##@Development Run Go and JS tests
|
||||
@(cd ./ui && npm run test:ci)
|
||||
@(cd ./ui && npm run test)
|
||||
.PHONY: testall
|
||||
|
||||
lint: ##@Development Lint Go code
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
@PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
@@ -59,16 +64,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 +161,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"
|
||||
@@ -71,6 +72,7 @@ type configOptions struct {
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
@@ -92,6 +94,7 @@ type configOptions struct {
|
||||
PID pidOptions
|
||||
Inspect inspectOptions
|
||||
Subsonic subsonicOptions
|
||||
LyricsPriority string
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
@@ -108,7 +111,6 @@ type configOptions struct {
|
||||
DevActivityPanel bool
|
||||
DevActivityPanelUpdateRate time.Duration
|
||||
DevSidebarPlaylists bool
|
||||
DevEnableBufferedScrobble bool
|
||||
DevShowArtistPage bool
|
||||
DevOffsetOptimize int
|
||||
DevArtworkMaxRequests int
|
||||
@@ -128,7 +130,11 @@ 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
|
||||
PurgeMissing string // Values: "never", "always", "full"
|
||||
}
|
||||
|
||||
type subsonicOptions struct {
|
||||
@@ -139,6 +145,7 @@ type subsonicOptions struct {
|
||||
}
|
||||
|
||||
type TagConf struct {
|
||||
Ignore bool `yaml:"ignore"`
|
||||
Aliases []string `yaml:"aliases"`
|
||||
Type string `yaml:"type"`
|
||||
MaxLength int `yaml:"maxLength"`
|
||||
@@ -271,6 +278,7 @@ func Load(noConfigDump bool) {
|
||||
validateScanSchedule,
|
||||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
@@ -302,12 +310,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 {
|
||||
@@ -375,6 +384,24 @@ func validatePlaylistsPath() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePurgeMissingOption() error {
|
||||
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
||||
valid := false
|
||||
for _, v := range allowedValues {
|
||||
if v == Server.Scanner.PurgeMissing {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
||||
log.Error(err.Error())
|
||||
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateScanSchedule() error {
|
||||
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
||||
Server.Scanner.Schedule = ""
|
||||
@@ -414,7 +441,7 @@ func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
}
|
||||
|
||||
func init() {
|
||||
func setViperDefaults() {
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("cachefolder", "")
|
||||
viper.SetDefault("datafolder", ".")
|
||||
@@ -451,7 +478,6 @@ func init() {
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
|
||||
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
@@ -466,6 +492,7 @@ func init() {
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enableinsightscollector", true)
|
||||
@@ -473,31 +500,29 @@ func init() {
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
viper.SetDefault("passwordencryptionkey", "")
|
||||
|
||||
viper.SetDefault("reverseproxyuserheader", "Remote-User")
|
||||
viper.SetDefault("reverseproxywhitelist", "")
|
||||
|
||||
viper.SetDefault("prometheus.enabled", false)
|
||||
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
||||
viper.SetDefault("prometheus.password", "")
|
||||
|
||||
viper.SetDefault("jukebox.enabled", false)
|
||||
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
||||
viper.SetDefault("jukebox.default", "")
|
||||
viper.SetDefault("jukebox.adminonly", true)
|
||||
|
||||
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("scanner.purgemissing", "never")
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
|
||||
viper.SetDefault("agents", "lastfm,spotify")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
@@ -507,29 +532,23 @@ func init() {
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
|
||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||
|
||||
viper.SetDefault("backup.path", "")
|
||||
viper.SetDefault("backup.schedule", "")
|
||||
viper.SetDefault("backup.count", 0)
|
||||
|
||||
viper.SetDefault("pid.track", consts.DefaultTrackPID)
|
||||
viper.SetDefault("pid.album", consts.DefaultAlbumPID)
|
||||
|
||||
viper.SetDefault("inspect.enabled", true)
|
||||
viper.SetDefault("inspect.maxrequests", 1)
|
||||
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devenableprofiler", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
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)
|
||||
@@ -544,7 +563,15 @@ func init() {
|
||||
viper.SetDefault("devenableplayerinsights", true)
|
||||
}
|
||||
|
||||
func init() {
|
||||
setViperDefaults()
|
||||
}
|
||||
|
||||
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 +595,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 ""
|
||||
}
|
||||
|
||||
51
conf/configuration_test.go
Normal file
51
conf/configuration_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
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()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
func(format string) {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
// Initialize config with the test file
|
||||
conf.InitConfig(filename)
|
||||
// Load the configuration (with noConfigDump=true)
|
||||
conf.Load(true)
|
||||
|
||||
// Execute the format-specific assertions
|
||||
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
|
||||
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
|
||||
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
||||
|
||||
// The config file used should be the one we created
|
||||
Expect(conf.Server.ConfigFile).To(Equal(filename))
|
||||
},
|
||||
Entry("TOML format", "toml"),
|
||||
Entry("YAML format", "yaml"),
|
||||
Entry("INI format", "ini"),
|
||||
Entry("JSON format", "json"),
|
||||
)
|
||||
})
|
||||
7
conf/export_test.go
Normal file
7
conf/export_test.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package conf
|
||||
|
||||
func ResetConf() {
|
||||
Server = &configOptions{}
|
||||
}
|
||||
|
||||
var SetViperDefaults = setViperDefaults
|
||||
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
|
||||
@@ -14,6 +14,9 @@ const (
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
|
||||
LastScanErrorKey = "LastScanError"
|
||||
LastScanTypeKey = "LastScanType"
|
||||
LastScanStartTimeKey = "LastScanStartTime"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
@@ -112,6 +115,12 @@ const (
|
||||
InsightsInitialDelay = 30 * time.Minute
|
||||
)
|
||||
|
||||
const (
|
||||
PurgeMissingNever = "never"
|
||||
PurgeMissingAlways = "always"
|
||||
PurgeMissingFull = "full"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultDownsamplingFormat = "opus"
|
||||
DefaultTranscodings = []struct {
|
||||
@@ -151,13 +160,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
|
||||
}()
|
||||
)
|
||||
|
||||
@@ -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,6 +344,8 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
@@ -351,6 +353,8 @@ func init() {
|
||||
return nil
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
// Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
|
||||
@@ -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 = newArtistArtworkReader(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:
|
||||
|
||||
@@ -4,7 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -15,11 +19,11 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// BFR Fix tests
|
||||
var _ = XDescribe("Artwork", func() {
|
||||
var _ = Describe("Artwork", func() {
|
||||
var aw *artwork
|
||||
var ds model.DataStore
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
var folderRepo *fakeFolderRepo
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
||||
var arMultipleCovers model.Artist
|
||||
@@ -30,20 +34,21 @@ var _ = XDescribe("Artwork", func() {
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3"}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"}
|
||||
//alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/artist/an-album/front.png"}
|
||||
//alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"}
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
//Paths: []string{"tests/fixtures/artist/an-album"},
|
||||
//ImageFiles: "tests/fixtures/artist/an-album/cover.jpg" + consts.Zwsp +
|
||||
// "tests/fixtures/artist/an-album/front.png" + consts.Zwsp +
|
||||
// "tests/fixtures/artist/an-album/artist.png",
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
FolderIDs: []string{"f1"},
|
||||
AlbumArtistID: "777",
|
||||
}
|
||||
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
|
||||
@@ -65,6 +70,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = nil
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alEmbedNotFound,
|
||||
@@ -87,12 +93,17 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Context("External images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyExternal,
|
||||
alExternalNotFound,
|
||||
})
|
||||
})
|
||||
It("returns external cover", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
@@ -100,6 +111,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
|
||||
})
|
||||
It("returns ErrUnavailable if external file is not available", func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = aw.Reader(ctx)
|
||||
@@ -108,6 +120,10 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png", "artist.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
@@ -130,6 +146,10 @@ var _ = XDescribe("Artwork", func() {
|
||||
Describe("artistArtworkReader", func() {
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
arMultipleCovers,
|
||||
})
|
||||
@@ -143,7 +163,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -157,12 +177,16 @@ var _ = XDescribe("Artwork", func() {
|
||||
Describe("mediafileArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if mediafile is not in the DB", func() {
|
||||
_, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
||||
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alOnlyExternal,
|
||||
@@ -185,11 +209,17 @@ var _ = XDescribe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(io.ReadAll(r)).To(Equal([]byte("content from ffmpeg")))
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(data).ToNot(BeEmpty())
|
||||
Expect(path).To(Equal("tests/fixtures/test.ogg"))
|
||||
})
|
||||
It("returns album cover if cannot read embed artwork", func() {
|
||||
// Force fromTag to fail
|
||||
mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg"
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed())
|
||||
// Simulate ffmpeg error
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
@@ -207,6 +237,10 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Describe("resizedArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
@@ -241,12 +275,13 @@ var _ = XDescribe("Artwork", func() {
|
||||
DescribeTable("resize",
|
||||
func(format string, landscape bool, size int) {
|
||||
coverFileName := "cover." + format
|
||||
//dirName := createImage(format, landscape, size)
|
||||
dirName := createImage(format, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
//ImageFiles: filepath.Join(dirName, coverFileName),
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
@@ -270,24 +305,24 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
|
||||
//func createImage(format string, landscape bool, size int) string {
|
||||
// var img image.Image
|
||||
//
|
||||
// if landscape {
|
||||
// img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
// } else {
|
||||
// img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
// }
|
||||
//
|
||||
// tmpDir := GinkgoT().TempDir()
|
||||
// f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
// defer f.Close()
|
||||
// switch format {
|
||||
// case "png":
|
||||
// _ = png.Encode(f, img)
|
||||
// case "jpg":
|
||||
// _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
// }
|
||||
//
|
||||
// return tmpDir
|
||||
//}
|
||||
func createImage(format string, landscape bool, size int) string {
|
||||
var img image.Image
|
||||
|
||||
if landscape {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
} else {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
}
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
_ = png.Encode(f, img)
|
||||
case "jpg":
|
||||
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
// If the file cache is disabled, return a NOOP implementation
|
||||
if cache.Disabled(context.Background()) {
|
||||
log.Debug("Image cache disabled. Cache warmer will not run")
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
@@ -53,6 +59,9 @@ type cacheWarmer struct {
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
if a.cache.Disabled(context.Background()) {
|
||||
return
|
||||
}
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.buffer[artID] = struct{}{}
|
||||
@@ -74,6 +83,17 @@ func (a *cacheWarmer) run(ctx context.Context) {
|
||||
break
|
||||
}
|
||||
|
||||
if a.cache.Disabled(ctx) {
|
||||
a.mutex.Lock()
|
||||
pending := len(a.buffer)
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
if pending > 0 {
|
||||
log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If cache not available, keep waiting
|
||||
if !a.cache.Available(ctx) {
|
||||
if len(a.buffer) > 0 {
|
||||
|
||||
216
core/artwork/cache_warmer_test.go
Normal file
216
core/artwork/cache_warmer_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CacheWarmer", func() {
|
||||
var (
|
||||
fc *mockFileCache
|
||||
aw *mockArtwork
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
fc = &mockFileCache{}
|
||||
aw = &mockArtwork{}
|
||||
})
|
||||
|
||||
Context("initialization", func() {
|
||||
It("returns noop when cache is disabled", func() {
|
||||
fc.SetDisabled(true)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when ImageCacheSize is 0", func() {
|
||||
conf.Server.ImageCacheSize = "0"
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when EnableArtworkPrecache is false", func() {
|
||||
conf.Server.EnableArtworkPrecache = false
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns real implementation when properly configured", func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*cacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("buffer management", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("drops buffered items when cache becomes disabled", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-test"))
|
||||
fc.SetDisabled(true)
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("adds multiple items to buffer", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("error handling", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("continues processing after artwork retrieval error", func() {
|
||||
aw.err = errors.New("artwork error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("continues processing after cache error", func() {
|
||||
fc.err = errors.New("cache error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("background processing", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("processes items in batches", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
for i := 0; i < 5; i++ {
|
||||
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
||||
}
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("wakes up on new items", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
|
||||
// Add first batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
|
||||
// Add second batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockArtwork struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
if m.err != nil {
|
||||
return nil, time.Time{}, m.err
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||
}
|
||||
|
||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||
}
|
||||
|
||||
type mockFileCache struct {
|
||||
disabled atomic.Bool
|
||||
ready atomic.Bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Available(ctx context.Context) bool {
|
||||
return f.ready.Load() && !f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Disabled(ctx context.Context) bool {
|
||||
return f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetDisabled(v bool) {
|
||||
f.disabled.Store(v)
|
||||
f.ready.Store(true)
|
||||
}
|
||||
@@ -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 newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -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:
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("artistReader", func() {
|
||||
var _ = Describe("artistArtworkReader", func() {
|
||||
var _ = Describe("loadArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
@@ -93,7 +94,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
}
|
||||
s.ID = id
|
||||
if V(s.ExpiresAt).IsZero() {
|
||||
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
|
||||
s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
|
||||
}
|
||||
|
||||
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[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 {
|
||||
|
||||
36
db/migrations/20250522013904_share_user_id_on_delete.sql
Normal file
36
db/migrations/20250522013904_share_user_id_on_delete.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE share_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
expires_at datetime,
|
||||
last_visited_at datetime,
|
||||
resource_ids varchar not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
user_id varchar(255) not null
|
||||
constraint share_user_id_fk
|
||||
references user
|
||||
on update cascade on delete cascade,
|
||||
downloadable bool not null default false,
|
||||
description varchar not null default '',
|
||||
resource_type varchar not null default '',
|
||||
contents varchar not null default '',
|
||||
format varchar not null default '',
|
||||
max_bit_rate integer not null default 0,
|
||||
visit_count integer not null default 0
|
||||
);
|
||||
|
||||
|
||||
INSERT INTO share_tmp(
|
||||
id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
|
||||
) SELECT id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
|
||||
FROM share;
|
||||
|
||||
DROP TABLE share;
|
||||
|
||||
ALTER TABLE share_tmp RENAME To share;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
@@ -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
|
||||
|
||||
77
go.mod
77
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
|
||||
@@ -34,33 +35,33 @@ require (
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/kardianos/service v1.2.2
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
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/prometheus/client_golang v1.21.1
|
||||
github.com/pressly/goose/v3 v3.24.3
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
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/exp v0.0.0-20250506013437-ce4c2cf36ca6
|
||||
golang.org/x/image v0.27.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/sync v0.14.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/text v0.25.0
|
||||
golang.org/x/time v0.11.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -68,51 +69,63 @@ 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-20250501235452-c0086092b71a // 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/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
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/magiconair/properties v1.8.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.1 // 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/cast v1.7.1 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.8.0 // 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/tools v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // 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
|
||||
)
|
||||
|
||||
175
go.sum
175
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.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/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-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
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=
|
||||
@@ -118,60 +130,60 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4/qc=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/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.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/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/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/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.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
|
||||
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
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.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
||||
@@ -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/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
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.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
|
||||
github.com/spf13/cast v1.8.0/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/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/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
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.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
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.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
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.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.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.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
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=
|
||||
@@ -327,12 +346,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
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/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
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.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||
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.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -32,6 +32,8 @@ type Artist struct {
|
||||
SimilarArtists Artists `structs:"similar_artists" json:"-"`
|
||||
ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt,omitempty"`
|
||||
|
||||
Missing bool `structs:"missing" json:"missing"`
|
||||
|
||||
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
||||
}
|
||||
@@ -76,7 +78,7 @@ type ArtistRepository interface {
|
||||
UpdateExternalInfo(a *Artist) error
|
||||
Get(id string) (*Artist, error)
|
||||
GetAll(options ...QueryOptions) (Artists, error)
|
||||
GetIndex(roles ...Role) (ArtistIndexes, error)
|
||||
GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
RefreshPlayCounts() (int64, error)
|
||||
|
||||
@@ -4,6 +4,7 @@ package criteria
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
@@ -40,6 +41,9 @@ func (c Criteria) OrderBy() string {
|
||||
} else {
|
||||
mapped = f.field
|
||||
}
|
||||
if f.numeric {
|
||||
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
|
||||
}
|
||||
}
|
||||
if c.Order != "" {
|
||||
if strings.EqualFold(c.Order, "asc") || strings.EqualFold(c.Order, "desc") {
|
||||
|
||||
@@ -109,6 +109,15 @@ var _ = Describe("Criteria", func() {
|
||||
)
|
||||
})
|
||||
|
||||
It("casts numeric tags when sorting", func() {
|
||||
AddTagNames([]string{"rate"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
goObj.Sort = "rate"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by random", func() {
|
||||
newObj := goObj
|
||||
newObj.Sort = "random"
|
||||
|
||||
@@ -54,11 +54,12 @@ var fieldMap = map[string]*mappedField{
|
||||
}
|
||||
|
||||
type mappedField struct {
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
isTag bool // true if the field is a tag imported from the file metadata
|
||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
isTag bool // true if the field is a tag imported from the file metadata
|
||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||
numeric bool // true if the field/tag should be treated as numeric
|
||||
}
|
||||
|
||||
func mapFields(expr map[string]any) map[string]any {
|
||||
@@ -145,6 +146,12 @@ type tagCond struct {
|
||||
|
||||
func (e tagCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
|
||||
// Check if this tag is marked as numeric in the fieldMap
|
||||
if fm, ok := fieldMap[e.tag]; ok && fm.numeric {
|
||||
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
||||
}
|
||||
|
||||
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
|
||||
e.tag, cond)
|
||||
if e.not {
|
||||
@@ -205,3 +212,16 @@ func AddTagNames(tagNames []string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddNumericTags marks the given tag names as numeric so they can be cast
|
||||
// when used in comparisons or sorting.
|
||||
func AddNumericTags(tagNames []string) {
|
||||
for _, name := range tagNames {
|
||||
name := strings.ToLower(name)
|
||||
if fm, ok := fieldMap[name]; ok {
|
||||
fm.numeric = true
|
||||
} else {
|
||||
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
var _ = BeforeSuite(func() {
|
||||
AddRoles([]string{"artist", "composer"})
|
||||
AddTagNames([]string{"genre"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
})
|
||||
|
||||
var _ = Describe("Operators", func() {
|
||||
@@ -46,7 +47,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())),
|
||||
|
||||
@@ -69,6 +69,15 @@ var _ = Describe("Operators", func() {
|
||||
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
|
||||
)
|
||||
|
||||
// TODO Validate operators that are not valid for each field type.
|
||||
XDescribeTable("ToSQL - Invalid Operators",
|
||||
func(op Expression, expectedError string) {
|
||||
_, _, err := op.ToSql()
|
||||
gomega.Expect(err).To(gomega.MatchError(expectedError))
|
||||
},
|
||||
Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
|
||||
)
|
||||
|
||||
Describe("Custom Tags", func() {
|
||||
It("generates valid SQL", func() {
|
||||
AddTagNames([]string{"mood"})
|
||||
@@ -78,6 +87,14 @@ var _ = Describe("Operators", func() {
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
|
||||
})
|
||||
It("casts numeric comparisons", func() {
|
||||
AddNumericTags([]string{"rate"})
|
||||
op := Lt{"rate": 6}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements(6))
|
||||
})
|
||||
It("skips unknown tag names", func() {
|
||||
op := EndsWith{"unknown": "value"}
|
||||
sql, args, _ := op.ToSql()
|
||||
|
||||
@@ -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 {
|
||||
@@ -317,6 +342,7 @@ type MediaFileRepository interface {
|
||||
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
|
||||
Delete(id string) error
|
||||
DeleteMissing(ids []string) error
|
||||
DeleteAllMissing() (int64, error)
|
||||
FindByPaths(paths []string) (MediaFiles, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
|
||||
@@ -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() {
|
||||
@@ -423,6 +564,58 @@ var _ = Describe("Participants", func() {
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
When("MUSICBRAINZ_PERFORMERID tag is set", func() {
|
||||
matchPerformer := func(name, orderName, subRole, mbid string) types.GomegaMatcher {
|
||||
return MatchFields(IgnoreExtras, Fields{
|
||||
"Artist": MatchFields(IgnoreExtras, Fields{
|
||||
"Name": Equal(name),
|
||||
"OrderArtistName": Equal(orderName),
|
||||
"MbzArtistID": Equal(mbid),
|
||||
}),
|
||||
"SubRole": Equal(subRole),
|
||||
})
|
||||
}
|
||||
|
||||
It("should map MBIDs to the correct performer", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
|
||||
"PERFORMER:BASS": {"Nathan East"},
|
||||
"MUSICBRAINZ_PERFORMERID:GUITAR": {"mbid1", "mbid2"},
|
||||
"MUSICBRAINZ_PERFORMERID:BASS": {"mbid3"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3)))
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Eric Clapton", "eric clapton", "Guitar", "mbid1"),
|
||||
matchPerformer("B.B. King", "b.b. king", "Guitar", "mbid2"),
|
||||
matchPerformer("Nathan East", "nathan east", "Bass", "mbid3"),
|
||||
))
|
||||
})
|
||||
|
||||
It("should handle mismatched performer names and MBIDs for sub-roles", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:VOCALS": {"Singer A", "Singer B", "Singer C"},
|
||||
"MUSICBRAINZ_PERFORMERID:VOCALS": {"mbid_vocals_a", "mbid_vocals_b"}, // Fewer MBIDs
|
||||
"PERFORMER:DRUMS": {"Drummer X"},
|
||||
"MUSICBRAINZ_PERFORMERID:DRUMS": {"mbid_drums_x", "mbid_drums_y"}, // More MBIDs
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4))) // 3 vocalists + 1 drummer
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Singer A", "singer a", "Vocals", "mbid_vocals_a"),
|
||||
matchPerformer("Singer B", "singer b", "Vocals", "mbid_vocals_b"),
|
||||
matchPerformer("Singer C", "singer c", "Vocals", ""),
|
||||
matchPerformer("Drummer X", "drummer x", "Drums", "mbid_drums_x"),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Other tags", func() {
|
||||
@@ -451,7 +644,6 @@ var _ = Describe("Participants", func() {
|
||||
Entry("REMIXER", model.RoleRemixer, "REMIXER"),
|
||||
Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"),
|
||||
Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"),
|
||||
// TODO PERFORMER
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ const (
|
||||
TagReleaseCountry TagName = "releasecountry"
|
||||
TagMedia TagName = "media"
|
||||
TagCatalogNumber TagName = "catalognumber"
|
||||
TagISRC TagName = "isrc"
|
||||
TagBPM TagName = "bpm"
|
||||
TagExplicitStatus TagName = "explicitstatus"
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -159,6 +162,17 @@ func tagNames() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func numericTagNames() []string {
|
||||
mappings := TagMappings()
|
||||
names := make([]string, 0)
|
||||
for k, cfg := range mappings {
|
||||
if cfg.Type == TagTypeInteger || cfg.Type == TagTypeFloat {
|
||||
names = append(names, string(k))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func loadTagMappings() {
|
||||
mappingsFile, err := resources.FS().Open("mappings.yaml")
|
||||
if err != nil {
|
||||
@@ -173,21 +187,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 {
|
||||
@@ -204,5 +239,6 @@ func init() {
|
||||
// used in smart playlists
|
||||
criteria.AddRoles(slices.Collect(maps.Keys(AllRoles)))
|
||||
criteria.AddTagNames(tagNames())
|
||||
criteria.AddNumericTags(numericTagNames())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -116,6 +116,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"name": fullTextFilter(r.tableName),
|
||||
"starred": booleanFilter,
|
||||
"role": roleFilter,
|
||||
"missing": booleanFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
@@ -134,7 +135,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
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ func (r *artistRepository) getIndexKey(a model.Artist) string {
|
||||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
options := model.QueryOptions{Sort: "name"}
|
||||
if len(roles) > 0 {
|
||||
roleFilters := slice.Map(roles, func(r model.Role) Sqlizer {
|
||||
@@ -211,6 +211,13 @@ func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, e
|
||||
})
|
||||
options.Filters = And(roleFilters)
|
||||
}
|
||||
if !includeMissing {
|
||||
if options.Filters == nil {
|
||||
options.Filters = Eq{"artist.missing": false}
|
||||
} else {
|
||||
options.Filters = And{options.Filters, Eq{"artist.missing": false}}
|
||||
}
|
||||
}
|
||||
artists, err := r.GetAll(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -237,10 +244,29 @@ func (r *artistRepository) purgeEmpty() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// markMissing marks artists as missing if all their albums are missing.
|
||||
func (r *artistRepository) markMissing() error {
|
||||
q := Expr(`
|
||||
with artists_with_non_missing_albums as (
|
||||
select distinct aa.artist_id
|
||||
from album_artists aa
|
||||
join album a on aa.album_id = a.id
|
||||
where a.missing = false
|
||||
)
|
||||
update artist
|
||||
set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums));
|
||||
`)
|
||||
_, err := r.executeSQL(q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking missing artists: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 +286,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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -94,7 +95,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("F"))
|
||||
@@ -112,7 +113,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
|
||||
// BFR Empty SortArtistName is not saved in the DB anymore
|
||||
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -134,7 +135,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -151,7 +152,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
})
|
||||
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -233,5 +234,91 @@ var _ = Describe("ArtistRepository", func() {
|
||||
Expect(m).ToNot(HaveKey("mbz_artist_id"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Missing artist visibility", func() {
|
||||
var raw *artistRepository
|
||||
var missing model.Artist
|
||||
|
||||
insertMissing := func() {
|
||||
missing = model.Artist{ID: "m1", Name: "Missing", OrderArtistName: "missing"}
|
||||
Expect(repo.Put(&missing)).To(Succeed())
|
||||
raw = repo.(*artistRepository)
|
||||
_, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missing.ID}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
removeMissing := func() {
|
||||
if raw != nil {
|
||||
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missing.ID}))
|
||||
}
|
||||
}
|
||||
|
||||
Context("regular user", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u1"})
|
||||
repo = NewArtistRepository(ctx, GetDBXBuilder())
|
||||
insertMissing()
|
||||
})
|
||||
|
||||
AfterEach(func() { removeMissing() })
|
||||
|
||||
It("does not return missing artist in GetAll", func() {
|
||||
artists, err := repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.missing": false}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("does not return missing artist in Search", func() {
|
||||
res, err := repo.Search("missing", 0, 10, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not return missing artist in GetIndex", func() {
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Only 2 artists should be present
|
||||
total := 0
|
||||
for _, ix := range idx {
|
||||
total += len(ix.Artists)
|
||||
}
|
||||
Expect(total).To(Equal(2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("admin user", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true})
|
||||
repo = NewArtistRepository(ctx, GetDBXBuilder())
|
||||
insertMissing()
|
||||
})
|
||||
|
||||
AfterEach(func() { removeMissing() })
|
||||
|
||||
It("returns missing artist in GetAll", func() {
|
||||
artists, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("returns missing artist in Search", func() {
|
||||
res, err := repo.Search("missing", 0, 10, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns missing artist in GetIndex when included", func() {
|
||||
idx, err := repo.GetIndex(true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
total := 0
|
||||
for _, ix := range idx {
|
||||
total += len(ix.Artists)
|
||||
}
|
||||
Expect(total).To(Equal(3))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -192,6 +192,15 @@ func (r *mediaFileRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) DeleteAllMissing() (int64, error) {
|
||||
user := loggedUser(r.ctx)
|
||||
if !user.IsAdmin {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
del := Delete(r.tableName).Where(Eq{"missing": true})
|
||||
return r.executeSQL(del)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) DeleteMissing(ids []string) error {
|
||||
user := loggedUser(r.ctx)
|
||||
if !user.IsAdmin {
|
||||
@@ -243,7 +252,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{
|
||||
|
||||
@@ -45,23 +45,37 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("delete tracks by id", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed())
|
||||
|
||||
Expect(mr.Delete(newID)).To(BeNil())
|
||||
Expect(mr.Delete(newID)).To(Succeed())
|
||||
|
||||
_, err := mr.Get(newID)
|
||||
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,
|
||||
}))
|
||||
It("deletes all missing files", func() {
|
||||
new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
|
||||
new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
|
||||
Expect(mr.Put(&new1)).To(Succeed())
|
||||
Expect(mr.Put(&new2)).To(Succeed())
|
||||
Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed())
|
||||
|
||||
adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true})
|
||||
adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder())
|
||||
|
||||
// Ensure the files are marked as missing and we have 2 of them
|
||||
count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err = adminRepo.DeleteAllMissing()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
|
||||
_, err = mr.Get(new1.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = mr.Get(new2.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
Context("Annotations", func() {
|
||||
|
||||
@@ -170,6 +170,7 @@ func (s *SQLStore) GC(ctx context.Context) error {
|
||||
err := chain.RunSequentially(
|
||||
trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }),
|
||||
trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }),
|
||||
trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }),
|
||||
trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }),
|
||||
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ func loggedUser(ctx context.Context) *model.User {
|
||||
}
|
||||
}
|
||||
|
||||
func isAdmin(ctx context.Context) bool {
|
||||
user := loggedUser(ctx)
|
||||
return user.IsAdmin
|
||||
}
|
||||
|
||||
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
||||
if r.tableName == "" {
|
||||
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -41,6 +41,9 @@ func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding,
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Put(t *model.Transcoding) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.put(t.ID, t)
|
||||
return err
|
||||
}
|
||||
@@ -69,6 +72,9 @@ func (r *transcodingRepository) NewInstance() interface{} {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
if !isAdmin(r.ctx) {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
t := entity.(*model.Transcoding)
|
||||
id, err := r.put(t.ID, t)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
@@ -78,6 +84,9 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
t := entity.(*model.Transcoding)
|
||||
t.ID = id
|
||||
_, err := r.put(id, t)
|
||||
@@ -88,6 +97,9 @@ func (r *transcodingRepository) Update(id string, entity interface{}, cols ...st
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Delete(id string) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(Eq{"id": id})
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
|
||||
96
persistence/transcoding_repository_test.go
Normal file
96
persistence/transcoding_repository_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TranscodingRepository", func() {
|
||||
var repo model.TranscodingRepository
|
||||
var adminRepo model.TranscodingRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(GinkgoT().Context())
|
||||
ctx = request.WithUser(ctx, regularUser)
|
||||
repo = NewTranscodingRepository(ctx, GetDBXBuilder())
|
||||
|
||||
adminCtx := log.NewContext(GinkgoT().Context())
|
||||
adminCtx = request.WithUser(adminCtx, adminUser)
|
||||
adminRepo = NewTranscodingRepository(adminCtx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up any transcoding created during the tests
|
||||
tc, err := adminRepo.FindByFormat("test_format")
|
||||
if err == nil {
|
||||
err = adminRepo.(*transcodingRepository).Delete(tc.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Admin User", func() {
|
||||
It("creates a new transcoding", func() {
|
||||
base, err := adminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = adminRepo.Put(&model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err := adminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(base + 1))
|
||||
})
|
||||
|
||||
It("updates an existing transcoding", func() {
|
||||
tr := &model.Transcoding{ID: "upd", Name: "old", TargetFormat: "test_format", DefaultBitRate: 100, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
tr.Name = "updated"
|
||||
err := adminRepo.Put(tr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, err := adminRepo.FindByFormat("test_format")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Name).To(Equal("updated"))
|
||||
})
|
||||
|
||||
It("deletes a transcoding", func() {
|
||||
err := adminRepo.Put(&model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = adminRepo.(*transcodingRepository).Delete("to-delete")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = adminRepo.Get("to-delete")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular User", func() {
|
||||
It("fails to create", func() {
|
||||
err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"})
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("fails to update", func() {
|
||||
tr := &model.Transcoding{ID: "updreg", Name: "old", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
tr.Name = "bad"
|
||||
err := repo.Put(tr)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
//_ = adminRepo.(*transcodingRepository).Delete("updreg")
|
||||
})
|
||||
|
||||
It("fails to delete", func() {
|
||||
tr := &model.Transcoding{ID: "delreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
err := repo.(*transcodingRepository).Delete("delreg")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
//_ = adminRepo.(*transcodingRepository).Delete("delreg")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
"year": "Urtea",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"bitRate": "Bit tasa",
|
||||
"bitRate": "Bit-tasa",
|
||||
"bitDepth": "Bit-sakonera",
|
||||
"sampleRate": "Lagin-tasa",
|
||||
"channels": "Kanalak",
|
||||
"discSubtitle": "Diskoaren azpititulua",
|
||||
"starred": "Gogokoa",
|
||||
"comment": "Iruzkina",
|
||||
@@ -25,14 +28,13 @@
|
||||
"quality": "Kalitatea",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Azkenekoz erreproduzitua:",
|
||||
"channels": "Kanalak",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"grouping": "",
|
||||
"mood": "",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": ""
|
||||
"grouping": "Multzokatzea",
|
||||
"mood": "Aldartea",
|
||||
"participants": "Partaide gehiago",
|
||||
"tags": "Traola gehiago",
|
||||
"mappedTags": "Esleitutako traolak",
|
||||
"rawTags": "Traola gordinak"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Erreproduzitu ondoren",
|
||||
@@ -52,25 +54,26 @@
|
||||
"duration": "Iraupena",
|
||||
"songCount": "abesti",
|
||||
"playCount": "Erreprodukzioak",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"name": "Izena",
|
||||
"genre": "Generoa",
|
||||
"compilation": "Konpilazioa",
|
||||
"year": "Urtea",
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"date": "Recording Date",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data:",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"released": "Argitaratua",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": ""
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"recordLabel": "Disketxea",
|
||||
"catalogNum": "Katalogo-zenbakia",
|
||||
"releaseType": "Mota",
|
||||
"grouping": "Multzokatzea",
|
||||
"media": "Multimedia",
|
||||
"mood": "Aldartea"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Erreproduzitu",
|
||||
@@ -84,7 +87,7 @@
|
||||
},
|
||||
"lists": {
|
||||
"all": "Guztiak",
|
||||
"random": "Aleatorioa",
|
||||
"random": "Aleatorioki",
|
||||
"recentlyAdded": "Berriki gehitutakoak",
|
||||
"recentlyPlayed": "Berriki entzundakoak",
|
||||
"mostPlayed": "Gehien entzundakoak",
|
||||
@@ -98,26 +101,26 @@
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
"songCount": "Abesti kopurua",
|
||||
"size": "Tamaina",
|
||||
"playCount": "Erreprodukzio kopurua",
|
||||
"rating": "Balorazioa",
|
||||
"genre": "Generoa",
|
||||
"size": "Tamaina",
|
||||
"role": ""
|
||||
"role": "Rola"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
"albumartist": "Albumeko egilea |||| Albumeko artistak",
|
||||
"artist": "Artista |||| Artistak",
|
||||
"composer": "Konpositorea |||| Konpositoreak",
|
||||
"conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak",
|
||||
"lyricist": "Hitzen egilea |||| Hitzen egileak",
|
||||
"arranger": "Moldatzailea |||| Moldatzaileak",
|
||||
"producer": "Produktorea |||| Produktoreak",
|
||||
"director": "Zuzendaria |||| Zuzendaria",
|
||||
"engineer": "Teknikaria |||| Teknikariak",
|
||||
"mixer": "Nahaslea |||| Nahasleak",
|
||||
"remixer": "Remixerra |||| Remixerrak",
|
||||
"djmixer": "DJ nahaslea |||| DJ nahasleak",
|
||||
"performer": "Interpretatzailea |||| Interpretatzaileak"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -216,6 +219,7 @@
|
||||
"username": "Partekatzailea:",
|
||||
"url": "URLa",
|
||||
"description": "Deskribapena",
|
||||
"downloadable": "Deskargatzea ahalbidetu?",
|
||||
"contents": "Edukia",
|
||||
"expiresAt": "Iraungitze-data:",
|
||||
"lastVisitedAt": "Azkenekoz bisitatu zen:",
|
||||
@@ -223,22 +227,25 @@
|
||||
"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",
|
||||
"remove_all": "Kendu guztia"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
"removed": "Faltan zeuden fitxategiak kendu dira"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -255,7 +262,7 @@
|
||||
"sign_in": "Sartu",
|
||||
"sign_in_error": "Autentifikazioak huts egin du, saiatu berriro",
|
||||
"logout": "Amaitu saioa",
|
||||
"insightsCollectionNote": ""
|
||||
"insightsCollectionNote": "Navidromek erabilera-datu anonimoak biltzen ditu\nproiektua hobetzen laguntzeko. Egin klik [hemen]\ngehiago ikasteko, eta datuak ez biltzeko eskatzeko,\nhala nahi izanez gero."
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Erabili hizkiak eta zenbakiak bakarrik",
|
||||
@@ -395,31 +402,33 @@
|
||||
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
|
||||
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
|
||||
"delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?",
|
||||
"remove_missing_title": "Kendu faltan dauden fitxategiak",
|
||||
"remove_missing_content": "Ziur hautatutako fitxategiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.",
|
||||
"remove_all_missing_title": "Kendu faltan dauden fitxategi guztiak",
|
||||
"remove_all_missing_content": "Ziur aurkitu ez diren fitxategi guztiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.",
|
||||
"notifications_blocked": "Nabigatzaileak jakinarazpenak blokeatzen ditu",
|
||||
"notifications_not_available": "Nabigatzaile hau ez da jakinarazpenekin bateragarria edo Navidrome ez da HTTPS erabiltzen ari",
|
||||
"lastfmLinkSuccess": "Last.fm konektatuta dago eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea gaituta dago",
|
||||
"lastfmLinkFailure": "Ezin izan da Last.fm-rekin konektatu",
|
||||
"lastfmUnlinkSuccess": "Last.fm deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea ezgaitu da",
|
||||
"lastfmUnlinkFailure": "Ezin izan da Last.fm deskonektatu",
|
||||
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
|
||||
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"openIn": {
|
||||
"lastfm": "Ikusi Last.fm-n",
|
||||
"musicbrainz": "Ikusi MusicBrainz-en"
|
||||
},
|
||||
"lastfmLink": "Irakurri gehiago…",
|
||||
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
|
||||
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua",
|
||||
"shareOriginalFormat": "Partekatu jatorrizko formatua",
|
||||
"shareDialogTitle": "Partekatu '%{name}' %{resource}",
|
||||
"shareBatchDialogTitle": "Partekatu %{resource} bat |||| Partekatu %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
|
||||
"shareSuccess": "URLa arbelera kopiatu da: %{url}",
|
||||
"shareFailure": "Errorea %{url} URLa arbelera kopiatzean",
|
||||
"downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})",
|
||||
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": ""
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Liburutegia",
|
||||
@@ -433,6 +442,7 @@
|
||||
"language": "Hizkuntza",
|
||||
"defaultView": "Bista, defektuz",
|
||||
"desktop_notifications": "Mahaigaineko jakinarazpenak",
|
||||
"lastfmNotConfigured": "Last.fm-ren API-gakoa ez dago konfiguratuta",
|
||||
"lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak",
|
||||
"listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak",
|
||||
"replaygain": "ReplayGain modua",
|
||||
@@ -441,14 +451,13 @@
|
||||
"none": "Bat ere ez",
|
||||
"album": "Albuma",
|
||||
"track": "Pista"
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albumak",
|
||||
"about": "Honi buruz",
|
||||
"playlists": "Zerrendak",
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak"
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
||||
"about": "Honi buruz"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Erreprodukzio-zerrenda",
|
||||
@@ -480,10 +489,10 @@
|
||||
"homepage": "Hasierako orria",
|
||||
"source": "Iturburu kodea",
|
||||
"featureRequests": "Eskatu ezaugarria",
|
||||
"lastInsightsCollection": "",
|
||||
"lastInsightsCollection": "Bildutako azken datuak",
|
||||
"insights": {
|
||||
"disabled": "",
|
||||
"waiting": ""
|
||||
"disabled": "Ezgaituta",
|
||||
"waiting": "Zain"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -493,7 +502,10 @@
|
||||
"quickScan": "Arakatze azkarra",
|
||||
"fullScan": "Arakatze sakona",
|
||||
"serverUptime": "Zerbitzariak piztuta daraman denbora",
|
||||
"serverDown": "LINEAZ KANPO"
|
||||
"serverDown": "LINEAZ KANPO",
|
||||
"scanType": "Mota",
|
||||
"status": "Errorea arakatzean",
|
||||
"elapsedTime": "Igarotako denbora"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidromeren laster-teklak",
|
||||
@@ -509,4 +521,4 @@
|
||||
"current_song": "Uneko abestia"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user