Compare commits

..

53 Commits

Author SHA1 Message Date
Deluan
2a81ec9b5a docs: update plugin system design with security enhancements
Added local network access control flag, plugin verification system, and capabilities declaration to the HLD. Updated implementation plan to remove UserPreference functionality from PoC scope while maintaining security features like local network control and hash verification. Added future extensions section outlining potential plugin types beyond metadata agents.
2025-04-13 10:18:28 -04:00
Deluan
4d4625c766 docs: Add table of contents to plugin HLD
Add a two-level TOC with links to all major sections of the document
2025-04-13 00:15:08 -04:00
Deluan
626e5a7bb0 docs: Add plugin directory structure info and implementation plan
- Add section 5.6 describing the plugin directory structure\n- Add new implementation plan document with phased approach\n- Include progress tracking with checkboxes
2025-04-13 00:10:23 -04:00
Deluan
41535b54f5 docs: Add agent system integration to plugin HLD
- Add section 3.7 describing integration with existing agent system\n- Include visualization diagram of plugin-agent architecture\n- Document plugin adapter approach and future evolutions
2025-04-12 23:30:08 -04:00
Deluan
7e835b4557 Update plugin initialization flow to pass configuration during Init() 2025-04-12 22:05:45 -04:00
Deluan
67c4fa2c9d Improve sequence diagram by breaking down Navidrome Core into specific components 2025-04-12 21:38:05 -04:00
Deluan
438fd93d8e Clarify Permission Manager responsibilities in HLD 2025-04-12 21:33:54 -04:00
Deluan
96f446c4a0 Add support for wildcard URL permissions in plugin system 2025-04-12 18:46:27 -04:00
Deluan
449dd53edf Add High-Level Design document for plugin system 2025-04-12 17:19:25 -04:00
Deluan
a6f1f7b7e3 fix(server): albumtype in Smart Playlists
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-11 23:53:16 -04:00
Deluan Quintão
49b8cfc261 fix(artwork): always select the coverArt of the first disc in multi-disc albums (#3950)
* feat(artwork): sort image files for consistent cover art selection

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(artwork): improve album cover art selection by considering disc numbers

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-11 23:39:57 -04:00
Deluan
bcea8b832a chore(deps): update Go version to 1.24.2 in go.mod 2025-04-11 23:18:00 -04:00
Deluan Quintão
58367afaea refactor: external_metadata -> external.Provider (#3903)
* tests for TopSongs

Signed-off-by: Deluan <deluan@navidrome.org>

* convert to Ginkgo

Signed-off-by: Deluan <deluan@navidrome.org>

* consolidate tests

Signed-off-by: Deluan <deluan@navidrome.org>

* rename external metadata -wip

Signed-off-by: Deluan <deluan@navidrome.org>

* rename external metadata to extdata.Provider

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor test helpers

Signed-off-by: Deluan <deluan@navidrome.org>

* remove reflection

Signed-off-by: Deluan <deluan@navidrome.org>

* use mock.Mock

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* fix

Signed-off-by: Deluan <deluan@navidrome.org>

* receive Agents interface in Provider constructor

Signed-off-by: Deluan <deluan@navidrome.org>

* use mock for Agents

Signed-off-by: Deluan <deluan@navidrome.org>

* tests for SimilarSongs

Signed-off-by: Deluan <deluan@navidrome.org>

* remove duplication

Signed-off-by: Deluan <deluan@navidrome.org>

* ArtistImage tests

Signed-off-by: Deluan <deluan@navidrome.org>

* AlbumImage tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix provider error handling

Signed-off-by: Deluan <deluan@navidrome.org>

* UpdateAlbumInfo tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* UpdateAlbumInfo tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* UpdateArtistInfo tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* clean up

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* fix test descriptions

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: rename extdata package to external

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-08 21:11:09 -04:00
Deluan
6b59f5f73a feat(ui): add genre and mood fields to AlbumSongs component
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-08 18:13:37 -04:00
Deluan Quintão
5f0c1e7387 chore(deps) upgrade Go dependencies, including golangci-lint (#3937)
* chore(deps): update Go dependencies

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): upgrade golangci-lint

Signed-off-by: Deluan <deluan@navidrome.org>

* build: upgrade golangci-lint-action to v7

Signed-off-by: Deluan <deluan@navidrome.org>

* go mod tidy

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-07 19:42:00 -03:00
Deluan Quintão
a057a680f1 fix(ui): update Greek, Esperanto, Polish, Russian, Turkish translations from POEditor (#3894)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-04-05 08:54:29 -03:00
Deluan Quintão
f9081bbe6b fix(server): first user created should be admin, when using reverse proxy (#3920)
Fix #3902

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-05 08:24:14 -03:00
Deluan Quintão
73eb0e254b feat(ui): add mood column to Album and Song list views (#3925)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-05 08:23:52 -03:00
Deluan Quintão
2b84c574ba fix: restore old date display/sort behaviour (#3862)
* fix(server): bring back legacy date mappings

Signed-off-by: Deluan <deluan@navidrome.org>

* reuse the mapDates logic in the legacyReleaseDate function

Signed-off-by: Deluan <deluan@navidrome.org>

* fix mappings

Signed-off-by: Deluan <deluan@navidrome.org>

* show original and release dates in album grid

Signed-off-by: Deluan <deluan@navidrome.org>

* fix tests based on new year mapping

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): prefer returning original_year over (recording) year
when sorting albums

Signed-off-by: Deluan <deluan@navidrome.org>

* fix case when we don't have originalYear

Signed-off-by: Deluan <deluan@navidrome.org>

* show all dates in album's info, and remove the recording date from the album page

Signed-off-by: Deluan <deluan@navidrome.org>

* better?

Signed-off-by: Deluan <deluan@navidrome.org>

* add snapshot tests for Album Details

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): sort order for getAlbumList?type=byYear

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-30 17:06:58 -04:00
Deluan
88f87e6c4f chore: replace album placeholder
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-30 13:41:32 -04:00
Deluan
cf100c4eb4 chore(subsonic): update snapshot tests to use version 1.16.1 2025-03-27 22:50:22 -04:00
Deluan Quintão
5ab345c83e chore(server): add more info to scrobble errors logs (#3889)
* chore(server): add more info to scrobble errors

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(server): add more info to scrobble errors

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(server): add more info to scrobble errors

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-27 18:57:06 -04:00
Deluan
46a2ec0ba1 feat(ui): hide absolute paths from regular users
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-25 20:05:24 -04:00
Deluan
3394580413 feat(ui): add Norwegian translation
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-25 17:43:25 -04:00
Michachatz
112ea281d9 feat(ui): add Greek translation (#3892)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-25 17:33:58 -04:00
Deluan Quintão
c837838d58 fix(ui): update French, Polish, Turkish translations from POEditor (#3834)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-03-24 17:52:03 -04:00
matteo00gm
9e9465567d fix(ui): update Italian translations (#3885) 2025-03-24 17:49:23 -04:00
Deluan
651ce163c7 fix(ui): sort playlist by album_artist, bpm and channels
fix #3878

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-24 16:41:54 -04:00
Deluan Quintão
55ce28b2c6 fix(bfr): force upgrade to read all folders. (#3871)
* chore(scanner): add trace logs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(bfr): force upgrade to read all folders. It was skipping folders for certain timezones

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-24 15:22:59 -04:00
Deluan
d331ee904b fix(ui): sort playlist by year
fix #3878

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-24 15:08:17 -04:00
Deluan
3a0ce6aafa fix(scanner): elapsed time for folder processing is wrong in the logs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-23 12:36:38 -04:00
Deluan
1806552ef6 chore: remove more outdated TODOs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-23 11:53:43 -04:00
Deluan
223e88d481 chore: remove some BFR-related TODOs that are not valid anymore
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-23 11:37:20 -04:00
Deluan Quintão
57e0f6d3ea feat(server): custom ArtistJoiner config (#3873)
* feat(server): custom ArtistJoiner config

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(ui): organize ArtistLinkField, add tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): use display artist

* feat(ui): use display artist

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-23 10:53:21 -04:00
Deluan
1c691ac0e6 feat(docker): automatically loads a navidrome.toml file from /data, if available
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 17:33:56 -04:00
Deluan
264d73d73e fix(server): don't break if the ND_CONFIGFILE does not exist
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 17:08:03 -04:00
Deluan
296259d781 feat(ui): show bitDepth in song info dialog
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 15:48:29 -04:00
Deluan
3f9d173495 fix(scanner): support ID3v2 embedded images in WAV files
Fix #3867

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 15:48:07 -04:00
Deluan
b386981b7f fix(scanner): better log message when AutoImportPlaylists is disabled
Fix #3861

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 15:08:26 -04:00
Deluan Quintão
be7cb59dc5 fix(scanner): allow disabling splitting with the Tags config option (#3869)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 12:34:35 -04:00
Nicolas Derive
63dc0e2062 fix(ui): update Français, reorder translation according to en.json template (#3839)
Update french translation and reorder the file the same way as the en.json template, making comparison easier.
2025-03-22 12:31:32 -04:00
Xabi
1e1dce92b6 fix(ui): update Basque translation (#3864)
* Update Basque localisation

added missing strings

* Update eu.json
2025-03-22 12:29:43 -04:00
Deluan
d78c6f6a04 fix(subsonic): ArtistID3 should contain list of AlbumID3
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-20 22:10:46 -04:00
Deluan Quintão
59ece40393 fix(server): better embedded artwork extraction with ffmpeg (#3860)
- `-map 0:v` selects all video streams from the input
- `-map -0:V` excludes all "main" video streams (capital V)

This combination effectively selects only the attached pictures

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-20 19:26:40 -04:00
Deluan
491210ac12 fix(scanner): ignore NaN ReplayGain values
Fix: https://github.com/navidrome/navidrome/issues/3858
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-20 12:42:09 -04:00
Deluan
cd552a55ef fix(scanner): pass datafolder and cachefolder to scanner subprocess
Fix #3831

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-19 22:15:20 -04:00
Deluan
ee2c2b19e9 fix(dockerfile): remove the healthcheck, it gives more headaches than benefits.
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-19 20:18:56 -04:00
Deluan
0147bb5f12 chore(deps): upgrade viper to 1.20.0, add tests for the supported config formats
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-18 19:16:47 -04:00
Rob Emery
1ed8930107 fix(msi): don't override custom ini config (#3836)
Previously addLine would add-or-update, resulting in the custom settings being overriden on upgrade. createLine will only add to the ini if the key doesn't already exist.
2025-03-18 18:23:04 -04:00
Deluan
e457f21306 chore(server): show square flag in resize artwork logs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-18 12:43:52 -04:00
Deluan Quintão
b04647309f chore(deps): upgrade to Go 1.24.1 (#3851)
* chore(deps): upgrade to Go 1.24.1

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): add reflex as go.mod tool

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): add wire as go.mod tool

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): add goimports as go.mod tool

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): add ginkgo as go.mod tool

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-17 21:08:10 -04:00
Deluan Quintão
2adb098f32 fix(scanner): fix displayArtist logic (#3835)
* fix displayArtist logic

Signed-off-by: Deluan <deluan@navidrome.org>

* remove unneeded value

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* Use first albumartist if it cannot figure out the display name

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-17 19:21:33 -04:00
Kendall Garner
212887214c fix(ui): minor icon inconsistencies and "no missing files" translation (#3837)
* chore(ui): Fix minor inconsistencies

1. The icons in the user menu are a mix of MUI and react-icons. Move them all to react-icons, and use a standard size (24px)
2. On missing files page, provide a custom Empty component that just removes 'yet'

* use RA's builtin support for custom empty message

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-03-16 19:39:19 -04:00
234 changed files with 7331 additions and 1329 deletions

View File

@@ -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"

View File

@@ -71,7 +71,7 @@ jobs:
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v7
with:
version: latest
problem-matchers: true

View File

@@ -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$

View File

@@ -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"]

View File

@@ -29,11 +29,11 @@ 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
test: ##@Development Run Go tests
@@ -49,7 +49,7 @@ testall: testrace ##@Development Run Go and JS tests
.PHONY: testall
lint: ##@Development Lint Go code
go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v --timeout 5m
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code
@@ -59,16 +59,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

View File

@@ -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

View File

@@ -201,41 +201,42 @@ 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();
}
// ----- WMA
if (TagLib::ASF::File *
asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
else 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");
}
// ----- 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();
}
}
return hasCover;
}

View File

@@ -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)

View File

@@ -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"
@@ -128,6 +129,7 @@ type scannerOptions struct {
WatcherWait time.Duration
ScanOnStartup bool
Extractor string
ArtistJoiner string
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
}
@@ -304,7 +306,6 @@ 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
@@ -494,6 +495,7 @@ func init() {
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
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)
@@ -549,6 +551,10 @@ func init() {
}
func InitConfig(cfgFile string) {
codecRegistry := viper.NewCodecRegistry()
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
cfgFile = getConfigFile(cfgFile)
if cfgFile != "" {
// Use config file from the flag.
@@ -572,9 +578,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 ""
}

View File

@@ -0,0 +1,50 @@
package conf_test
import (
"fmt"
"path/filepath"
"testing"
. "github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/viper"
)
func TestConfiguration(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Configuration Suite")
}
var _ = Describe("Configuration", func() {
BeforeEach(func() {
// Reset viper configuration
viper.Reset()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
ResetConf()
})
DescribeTable("should load configuration from",
func(format string) {
filename := filepath.Join("testdata", "cfg."+format)
// Initialize config with the test file
InitConfig(filename)
// Load the configuration (with noConfigDump=true)
Load(true)
// Execute the format-specific assertions
Expect(Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
Expect(Server.UIWelcomeMessage).To(Equal("Welcome " + format))
Expect(Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
// The config file used should be the one we created
Expect(Server.ConfigFile).To(Equal(filename))
},
Entry("TOML format", "toml"),
Entry("YAML format", "yaml"),
Entry("INI format", "ini"),
Entry("JSON format", "json"),
)
})

5
conf/export_test.go Normal file
View File

@@ -0,0 +1,5 @@
package conf
func ResetConf() {
Server = &configOptions{}
}

6
conf/testdata/cfg.ini vendored Normal file
View 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
View File

@@ -0,0 +1,12 @@
{
"musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json",
"Tags": {
"custom": {
"aliases": [
"json",
"test"
]
}
}
}

5
conf/testdata/cfg.toml vendored Normal file
View File

@@ -0,0 +1,5 @@
musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml"
[Tags.custom]
aliases = ["toml", "test"]

7
conf/testdata/cfg.yaml vendored Normal file
View File

@@ -0,0 +1,7 @@
musicFolder: "/yaml/music"
uiWelcomeMessage: "Welcome yaml"
Tags:
custom:
aliases:
- yaml
- test

View File

@@ -151,13 +151,17 @@ var (
UnknownArtistID = id.NewHash(strings.ToLower(UnknownArtist))
VariousArtistsMbzId = "89ad4ac3-39f7-470e-963a-56509c546377"
ServerStart = time.Now()
ArtistJoiner = " • "
)
var InContainer = func() bool {
// Check if the /.nddockerenv file exists
if _, err := os.Stat("/.nddockerenv"); err == nil {
return true
}
return false
}()
var (
ServerStart = time.Now()
InContainer = func() bool {
// Check if the /.nddockerenv file exists
if _, err := os.Stat("/.nddockerenv"); err == nil {
return true
}
return false
}()
)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -8,7 +8,7 @@ import (
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -24,15 +24,15 @@ type Artwork interface {
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, em: em}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
}
type artwork struct {
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
em core.ExternalMetadata
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
provider external.Provider
}
type artworkReader interface {
@@ -115,9 +115,9 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
} else {
switch artID.Kind {
case model.KindArtistArtwork:
artReader, err = newArtistReader(ctx, a, artID, a.em)
artReader, err = newArtistReader(ctx, a, artID, a.provider)
case model.KindAlbumArtwork:
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.em)
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
case model.KindMediaFileArtwork:
artReader, err = newMediafileArtworkReader(ctx, a, artID)
case model.KindPlaylistArtwork:

View File

@@ -15,7 +15,7 @@ import (
. "github.com/onsi/gomega"
)
// BFR Fix tests
// TODO Fix tests
var _ = XDescribe("Artwork", func() {
var aw *artwork
var ds model.DataStore

View File

@@ -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
}

View 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")))
})
})
})

View File

@@ -14,6 +14,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
@@ -22,13 +23,13 @@ import (
type artistReader struct {
cacheKey
a *artwork
em core.ExternalMetadata
provider external.Provider
artist model.Artist
artistFolder string
imgFiles []string
}
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*artistReader, error) {
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
if err != nil {
return nil, err
@@ -53,7 +54,7 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
}
a := &artistReader{
a: artwork,
em: em,
provider: provider,
artist: *ar,
artistFolder: artistFolder,
imgFiles: imgFiles,
@@ -95,7 +96,7 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "external":
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.em))
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
case strings.HasPrefix(pattern, "album/"):
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
default:

View File

@@ -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

View File

@@ -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
}

270
core/external/extdata_helper_test.go vendored Normal file
View 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
View 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")
}

View File

@@ -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 == "" {

View 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)

View 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)

View 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
View 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())
})
})

View 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())
})
})

View 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())
})
})

View File

@@ -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"
)

View File

@@ -239,7 +239,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 {

View File

@@ -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,

View File

@@ -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
}

1037
docs/hld-plugins.md Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,305 @@
# Navidrome Plugin System Implementation Plan
## Progress Tracking
### Phase 1: Foundational Infrastructure
- [ ] 1.1: Plugin Manifest and Configuration
- [ ] 1.2: Basic WebAssembly Runtime Integration
- [ ] 1.3: Permission Management System
- [ ] 1.3.1: URL Allowlist Implementation
- [ ] 1.3.2: Local Network Access Control
- [ ] 1.3.3: Host Function Access Control
- [ ] 1.4: Project Structure and CLI Commands
- [ ] 1.5: Plugin Verification System
### Phase 2: Protocol Definition and Host Functions
- [ ] 2.1: Protocol Buffer Definitions
- [ ] 2.2: Host Function Implementation
- [ ] 2.3: Plugin Context Management
### Phase 3: Plugin Loading and Execution
- [ ] 3.1: WebAssembly Runtime Configuration
- [ ] 3.2: Testing Infrastructure
- [ ] 3.3: Plugin Developer Tools
### Phase 4: Agent Plugin Integration
- [ ] 4.1: Agent Plugin Adapter Implementation
- [ ] 4.2: Plugin Registration with Agent System
- [ ] 4.3: Last.fm Agent Plugin Implementation
- [ ] 4.4: Integration Testing
### Phase 5: Enhanced Management and User Experience
- [ ] 5.1: Enhanced CLI Management
- [ ] 5.2: Plugin Package Format
- [ ] 5.3: Runtime Monitoring
- [ ] 5.4: Administrative UI (Optional)
### Phase 6: Documentation and Release
- [ ] 6.1: User Documentation
- [ ] 6.2: Developer Documentation
- [ ] 6.3: Example Plugin Templates
- [ ] 6.4: Final Testing and Feature Flags
## Phase 1: Foundational Infrastructure
**Goal:** Establish the core plugin infrastructure without affecting existing functionality.
### 1.1: Plugin Manifest and Configuration
- Create plugin manifest schema and validation functions
- Add plugin-related configuration to `conf` package:
- Global plugin settings: enabled, directory, default limits
- Per-plugin settings: enabled, limits, configuration
- Add tests for manifest validation and configuration parsing
### 1.2: Basic WebAssembly Runtime Integration
- Add `knqyf263/go-plugin` dependency
- Create initial plugin loader that can:
- Discover plugin files in configured directory
- Read and validate manifests
- Basic security validation (no plugin execution yet)
- Add unit tests for plugin discovery and manifest loading
### 1.3: Permission Management System
- Implement the `PermissionManager` component:
- URL allowlist validation
- Host function allowlist validation
- Internal network access prevention
- Configuration access control
- Add comprehensive security tests for all permission rules
- Implement local network access control feature:
- Add `allowLocalNetwork` flag to manifest schema
- Update permission checks in HTTP requests
- Add configuration option for default behavior
- Add tests for local network access control
### 1.4: Project Structure and CLI Commands
- Create plugin-related directory structure:
```
plugins/
├── proto/ # Protocol Buffer definitions
├── manager.go # Plugin Manager implementation
├── host.go # Host function implementations
├── permission.go # Permission manager
└── adapters/ # Adapters for different plugin types
```
- Implement basic CLI commands for plugin management:
- `navidrome plugin list`
- `navidrome plugin info [name]`
### 1.5: Plugin Verification System
- Implement plugin binary integrity verification:
- Add hash calculation and storage during installation
- Add verification during plugin loading
- Create a local store for plugin hashes
- Add tests for plugin verification workflow
- Update CLI commands to display verification status
**Deliverable:** Foundation layer with security features including local network control and plugin verification.
## Phase 2: Protocol Definition and Host Functions
**Goal:** Define the communication protocol between Navidrome and plugins.
### 2.1: Protocol Buffer Definitions
- Define Protocol Buffer specifications for:
- Agent plugin interface
- Host functions interface
- Common request/response structures
- Generate Go code from Protocol Buffers
- Create test stubs for interface implementations
### 2.2: Host Function Implementation
- Implement core host functions:
- `GetConfig` for configuration access
- `Log` for plugin logging
- `HttpDo` for controlled HTTP access
- Add comprehensive tests for each host function
- Implement permission checks for all host functions
### 2.3: Plugin Context Management
- Create plugin context structure to track:
- Current plugin name
- Permission scope
- Runtime state
- Implement proper isolation between plugin calls
**Deliverable:** Complete protocol definition and host function implementations without executing actual plugins.
## Phase 3: Plugin Loading and Execution (Minimal)
**Goal:** Enable basic plugin loading and execution in isolation from the rest of the system.
### 3.1: WebAssembly Runtime Configuration
- Configure WebAssembly runtime with appropriate security settings
- Implement plugin initialization with configuration passing
- Add proper error handling for plugin loading failures
### 3.2: Testing Infrastructure
- Create test harness for plugin execution
- Implement simple test plugins for validation
- Add integration tests for plugin loading and execution
- Add tests for local network access
- Add tests for plugin verification and integrity checks
### 3.3: Plugin Developer Tools
- Implement development commands:
- `navidrome plugin dev [folder_path]`
- `navidrome plugin refresh [name]`
- Create basic development documentation
**Deliverable:** Working plugin loading and execution system that can be tested in isolation.
## Phase 4: Agent Plugin Integration
**Goal:** Connect the plugin system to the existing agent architecture.
### 4.1: Agent Plugin Adapter Implementation
- Create adapter that implements all agent interfaces:
- Convert between Protobuf and agent interfaces
- Implement proper error handling and timeouts
- Add trace logging for debugging
- Add unit tests for all adapter methods
- Update adapter to respect plugin's declared capabilities
### 4.2: Plugin Registration with Agent System
- Implement plugin registration with the existing agent system
- Extend configuration to support plugin agent ordering
- Make plugin agents respect the same priority system as built-in agents
### 4.3: Last.fm Agent Plugin Implementation
- Implement prototype Last.fm plugin as proof of concept
- Create plugin manifest with necessary permissions
- Add tests comparing plugin behavior to built-in agent
### 4.4: Integration Testing
- Add comprehensive integration tests for:
- Plugin discovery and loading
- Agent API functionality
- Error handling and recovery
- Configuration changes
**Deliverable:** Working plugin system with Last.fm plugin implementation that can be toggled via configuration without breaking existing functionality.
## Phase 5: Enhanced Management and User Experience
**Goal:** Improve plugin management and user experience.
### 5.1: Enhanced CLI Management
- Complete remaining CLI commands:
- `navidrome plugin install [file]`
- `navidrome plugin remove [name]`
- `navidrome plugin config-template [name]`
- Add command validation and error handling
### 5.2: Plugin Package Format
- Implement `.ndp` package format:
- Package creation
- Validation
- Installation
- Add tests for package integrity checking
### 5.3: Runtime Monitoring
- Add runtime statistics:
- Plugin execution time
- Resource usage
- Error tracking
- Implement health checks and recovery mechanisms
### 5.4: Administrative UI (Optional)
- Create basic admin UI for plugin management:
- View installed plugins
- Enable/disable plugins
- View permissions
- Configure plugins
**Deliverable:** Complete plugin management tooling with good user experience.
## Phase 6: Documentation and Release
**Goal:** Prepare the plugin system for production use and developer adoption.
### 6.1: User Documentation
- Create comprehensive user documentation:
- Plugin installation and management
- Configuration options
- Security considerations
- Troubleshooting
### 6.2: Developer Documentation
- Create plugin development guide:
- API reference
- Development workflow
- Best practices
- Examples
### 6.3: Example Plugin Templates
- Create starter templates for common plugin types:
- Basic agent plugin
- Custom service plugin
- Include CI/CD configurations
- Add examples for different permission scenarios:
- Standard external API access
- Local network access (with `allowLocalNetwork: true`)
- Different capability declarations
### 6.4: Final Testing and Feature Flags
- Add feature flag to enable/disable plugin system
- Perform comprehensive integration testing
- Address any final security concerns
**Deliverable:** Production-ready plugin system with documentation and examples.
## Risk Assessment and Mitigation
1. **Security Risks**
- **Risk**: Plugin execution could compromise system security
- **Mitigation**: Strict permission model, WebAssembly sandbox, URL validation
2. **Performance Impact**
- **Risk**: WebAssembly execution might be slower than native code
- **Mitigation**: Benchmarking, caching mechanisms, performance monitoring
3. **Backward Compatibility**
- **Risk**: Changes might break existing functionality
- **Mitigation**: Feature flags, phased integration, comprehensive testing
4. **User Experience**
- **Risk**: Plugin management could be complex for users
- **Mitigation**: Clear documentation, intuitive CLI, potential UI integration
5. **Developer Adoption**
- **Risk**: Plugin development might be too complex
- **Mitigation**: Clear documentation, example templates, developer tooling

View File

@@ -10,7 +10,7 @@
#
# This script does not handle file names that contain spaces.
gofmtcmd="go run golang.org/x/tools/cmd/goimports@latest"
gofmtcmd="go tool goimports"
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$')
[ -z "$gofiles" ] && exit 0

66
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/navidrome/navidrome
go 1.23.4
go 1.24.2
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
@@ -24,8 +24,9 @@ require (
github.com/fatih/structs v1.1.0
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.14.1
github.com/go-chi/jwtauth/v5 v5.3.2
github.com/go-chi/httprate v0.15.0
github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-viper/encoding/ini v0.1.1
github.com/gohugoio/hashstructure v0.5.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0
@@ -36,31 +37,31 @@ require (
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.4
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/mattn/go-sqlite3 v1.14.27
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.23.0
github.com/onsi/gomega v1.36.2
github.com/pelletier/go-toml/v2 v2.2.3
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.37.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
github.com/pressly/goose/v3 v3.24.1
github.com/pressly/goose/v3 v3.24.2
github.com/prometheus/client_golang v1.21.1
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.19.0
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
go.uber.org/goleak v1.3.0
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
golang.org/x/image v0.25.0
golang.org/x/net v0.37.0
golang.org/x/sync v0.12.0
golang.org/x/sys v0.31.0
golang.org/x/text v0.23.0
golang.org/x/image v0.26.0
golang.org/x/net v0.38.0
golang.org/x/sync v0.13.0
golang.org/x/sys v0.32.0
golang.org/x/text v0.24.0
golang.org/x/time v0.11.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -68,20 +69,25 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/reflex v0.3.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/creack/pty v1.1.11 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@@ -90,29 +96,37 @@ require (
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ogier/pflag v0.0.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/tools v0.31.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)
tool (
github.com/cespare/reflex
github.com/google/wire/cmd/wire
github.com/onsi/ginkgo/v2/ginkgo
golang.org/x/tools/cmd/goimports
)

151
go.sum
View File

@@ -14,10 +14,14 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -48,23 +52,28 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
github.com/go-chi/jwtauth/v5 v5.3.2 h1:s+ON3ATyyMs3Me0kqyuua6Rwu+2zqIIkL0GCaMarwvs=
github.com/go-chi/jwtauth/v5 v5.3.2/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
@@ -76,8 +85,9 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 h1:+J3r2e8+RsmN3vKfo75g0YSY61ms37qzPglu4p0sGro=
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -92,11 +102,6 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
@@ -105,11 +110,18 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -130,48 +142,48 @@ github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4
github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ=
github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
@@ -184,10 +196,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
@@ -201,16 +211,16 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
@@ -229,6 +239,12 @@ github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQ
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -240,19 +256,21 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -265,8 +283,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -274,8 +292,9 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -293,8 +312,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -315,8 +334,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -331,8 +350,8 @@ golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -344,17 +363,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=

View File

@@ -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"`

View File

@@ -46,7 +46,6 @@ var _ = Describe("Operators", func() {
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
// TODO These may be flaky
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),

View File

@@ -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 {

View File

@@ -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"))
})
})
})
})
})
})

View File

@@ -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)
}

View 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"),
)
})

View File

@@ -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
}

View File

@@ -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() {

View File

@@ -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,
)
}

View File

@@ -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() {

View File

@@ -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]
}

View File

@@ -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) {

View File

@@ -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.
}

View File

@@ -201,7 +201,7 @@ func loadTagMappings() {
aliases = oldValue.Aliases
}
split := cfg.Split
if len(split) == 0 {
if split == nil {
split = oldValue.Split
}
c := TagConf{

View File

@@ -97,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(),
@@ -184,7 +185,6 @@ func allRolesFilter(_ string, value interface{}) Sqlizer {
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...)
}

View File

@@ -85,7 +85,7 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error {
m["full_text"] = formatFullText(a.Name, a.SortArtistName)
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
// BFR: Better way to handle this?
// TODO: Better way to handle this?
if v, ok := m["sort_artist_name"]; !ok || v.(string) == "" {
delete(m, "sort_artist_name")
}
@@ -134,7 +134,6 @@ func roleFilter(_ string, role any) Sqlizer {
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
query := r.newSelect(options...).Columns("artist.*")
query = r.withAnnotation(query, "artist.id")
// BFR How to handle counts and sizes (per role)?
return query
}

View File

@@ -105,7 +105,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...)
}

View File

@@ -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

View File

@@ -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() {

View File

@@ -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.

View File

@@ -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="&apos;[ND_PORT]&apos;" />
<IniFile Id="ConfigurationMusicDir" Name="navidrome.ini" Action="addLine" Directory="INSTALLDIR" Key="MusicFolder" Section="default" Value="&apos;[ND_MUSICFOLDER]&apos;" />
<IniFile Id="ConfigurationDataDir" Name="navidrome.ini" Action="addLine" Directory="INSTALLDIR" Key="DataFolder" Section="default" Value="&apos;[ND_DATAFOLDER]&apos;" />
<IniFile Id="FFmpegPath" Name="navidrome.ini" Action="addLine" Directory="INSTALLDIR" Key="FFmpegPath" Section="default" Value="&apos;[INSTALLDIR]ffmpeg.exe&apos;" />
<IniFile Id="ConfigurationMusicDir" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="MusicFolder" Section="default" Value="&apos;[ND_MUSICFOLDER]&apos;" />
<IniFile Id="ConfigurationDataDir" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="DataFolder" Section="default" Value="&apos;[ND_DATAFOLDER]&apos;" />
<IniFile Id="FFmpegPath" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="FFmpegPath" Section="default" Value="&apos;[INSTALLDIR]ffmpeg.exe&apos;" />
</Component>
<Component Id='MainExecutable' Guid='e645aa06-8bbc-40d6-8d3c-73b4f5b76fd7' Win64="$(var.Win64)">

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 68 KiB

515
resources/i18n/el.json Normal file
View 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": "Μεταβείτε στο Τρέχον τραγούδι"
}
}
}

View File

@@ -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": ""
}
}
}

View File

@@ -216,6 +216,7 @@
"username": "Partekatzailea:",
"url": "URLa",
"description": "Deskribapena",
"downloadable": "Deskargatzea ahalbidetu?",
"contents": "Edukia",
"expiresAt": "Iraungitze-data:",
"lastVisitedAt": "Azkenekoz bisitatu zen:",
@@ -223,22 +224,24 @@
"format": "Formatua",
"maxBitRate": "Gehienezko bit tasa",
"updatedAt": "Eguneratze-data:",
"createdAt": "Sortze-data:",
"downloadable": "Deskargatzea ahalbidetu?"
}
"createdAt": "Sortze-data:"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "",
"name": "Fitxategia falta da|||| Fitxategiak falta dira",
"empty": "Ez da fitxategirik falta",
"fields": {
"path": "",
"size": "",
"updatedAt": ""
"path": "Bidea",
"size": "Tamaina",
"updatedAt": "Desagertze-data:"
},
"actions": {
"remove": ""
"remove": "Kendu"
},
"notifications": {
"removed": ""
"removed": "Faltan zeuden fitxategiak kendu dira"
}
}
},
@@ -509,4 +512,4 @@
"current_song": "Uneko abestia"
}
}
}
}

View File

@@ -26,7 +26,14 @@
"bpm": "BPM",
"playDate": "Derniers joués",
"channels": "Canaux",
"createdAt": "Date d'ajout"
"createdAt": "Date d'ajout",
"grouping": "Regroupement",
"mood": "Humeur",
"participants": "Participants supplémentaires",
"tags": "Étiquettes supplémentaires",
"mappedTags": "Étiquettes correspondantes",
"rawTags": "Étiquettes brutes",
"bitDepth": "Profondeur de bit"
},
"actions": {
"addToQueue": "Ajouter à la file",
@@ -58,7 +65,13 @@
"originalDate": "Original",
"releaseDate": "Sortie",
"releases": "Sortie |||| Sorties",
"released": "Sortie"
"released": "Sortie",
"recordLabel": "Label",
"catalogNum": "Numéro de catalogue",
"releaseType": "Type",
"grouping": "Regroupement",
"media": "Média",
"mood": "Humeur"
},
"actions": {
"playAll": "Lire",
@@ -89,7 +102,23 @@
"playCount": "Lectures",
"rating": "Classement",
"genre": "Genre",
"size": "Taille"
"size": "Taille",
"role": "Rôle"
},
"roles": {
"albumartist": "Artiste de l'album |||| Artistes de l'album",
"artist": "Artiste |||| Artistes",
"composer": "Compositeur |||| Compositeurs",
"conductor": "Chef d'orchestre |||| Chefs d'orchestre",
"lyricist": "Parolier |||| Paroliers",
"arranger": "Arrangeur |||| Arrangeurs",
"producer": "Producteur |||| Producteurs",
"director": "Réalisateur |||| Réalisateurs",
"engineer": "Ingénieur |||| Ingénieurs",
"mixer": "Mixeur |||| Mixeurs",
"remixer": "Remixeur |||| Remixeurs",
"djmixer": "Mixeur DJ |||| Mixeurs DJ",
"performer": "Interprète |||| Interprètes"
}
},
"user": {
@@ -152,7 +181,7 @@
"public": "Publique",
"updatedAt": "Mise à jour le",
"createdAt": "Créée le",
"songCount": "Titres",
"songCount": "Morceaux",
"comment": "Commentaire",
"sync": "Import automatique",
"path": "Importer depuis"
@@ -198,6 +227,21 @@
"createdAt": "Créé le",
"downloadable": "Autoriser les téléchargements ?"
}
},
"missing": {
"name": "Fichier manquant|||| Fichiers manquants",
"fields": {
"path": "Chemin",
"size": "Taille",
"updatedAt": "A disparu le"
},
"actions": {
"remove": "Supprimer"
},
"notifications": {
"removed": "Fichier(s) manquant(s) supprimé(s)"
},
"empty": "Aucun fichier manquant"
}
},
"ra": {
@@ -273,10 +317,10 @@
"error": "Un problème est survenu",
"list": "%{name}",
"loading": "Chargement",
"not_found": "Page manquante",
"not_found": "Introuvable",
"show": "%{name} #%{id}",
"empty": "Pas encore de %{name}.",
"invite": "Voulez-vous en créer ?"
"invite": "Voulez-vous en créer un ?"
},
"input": {
"file": {
@@ -375,7 +419,9 @@
"shareSuccess": "Lien copié vers le presse-papier : %{url}",
"shareFailure": "Erreur en copiant le lien %{url} vers le presse-papier",
"downloadDialogTitle": "Télécharger %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copier vers le presse-papier : Ctrl+C, Enter"
"shareCopyToClipboard": "Copier vers le presse-papier : Ctrl+C, Enter",
"remove_missing_title": "Supprimer les fichiers manquants",
"remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations"
},
"menu": {
"library": "Bibliothèque",

View File

@@ -53,12 +53,12 @@
"updatedAt": "Ultimo aggiornamento",
"comment": "Commento",
"rating": "Valutazione",
"createdAt": "",
"size": "",
"createdAt": "Data di creazione",
"size": "Dimensione",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"releaseDate": "Data di pubblicazione",
"releases": "Pubblicazione |||| Pubblicazioni",
"released": "Pubblicato"
},
"actions": {
"playAll": "Riproduci",
@@ -68,7 +68,7 @@
"addToPlaylist": "Aggiungi alla Playlist",
"download": "Scarica",
"info": "Informazioni",
"share": ""
"share": "Condividi"
},
"lists": {
"all": "Tutti",
@@ -89,7 +89,7 @@
"playCount": "Riproduzioni",
"rating": "Valutazione",
"genre": "Genere",
"size": ""
"size": "Dimensione"
}
},
"user": {
@@ -160,8 +160,8 @@
"selectPlaylist": "Aggiungi tracce alla playlist:",
"addNewPlaylist": "Aggiungi \"%{name}\"",
"export": "Esporta",
"makePublic": "",
"makePrivate": ""
"makePublic": "Rendi Pubblica",
"makePrivate": "Rendi Privata"
},
"message": {
"duplicate_song": "Aggiungere i duplicati",
@@ -169,9 +169,9 @@
}
},
"radio": {
"name": "",
"name": "Radio |||| Radio",
"fields": {
"name": "",
"name": "Nome",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",

514
resources/i18n/no.json Normal file
View File

@@ -0,0 +1,514 @@
{
"languageName": "Engelsk",
"resources": {
"song": {
"name": "Låt |||| Låter",
"fields": {
"albumArtist": "Album Artist",
"duration": "Tid",
"trackNumber": "#",
"playCount": "Avspillinger",
"title": "Tittel",
"artist": "Artist",
"album": "Album",
"path": "Filbane",
"genre": "Sjanger",
"compilation": "Samling",
"year": "År",
"size": "Filstørrelse",
"updatedAt": "Oppdatert kl",
"bitRate": "Bithastighet",
"discSubtitle": "Diskundertekst",
"starred": "Favoritt",
"comment": "Kommentar",
"rating": "Vurdering",
"quality": "Kvalitet",
"bpm": "BPM",
"playDate": "Sist spilt",
"channels": "Kanaler",
"createdAt": "",
"grouping": "",
"mood": "",
"participants": "",
"tags": "",
"mappedTags": "",
"rawTags": "",
"bitDepth": ""
},
"actions": {
"addToQueue": "Spill Senere",
"playNow": "Leke nå",
"addToPlaylist": "Legg til i spilleliste",
"shuffleAll": "Bland alle",
"download": "nedlasting",
"playNext": "Spill Neste",
"info": "Få informasjon"
}
},
"album": {
"name": "Album",
"fields": {
"albumArtist": "Album Artist",
"artist": "Artist",
"duration": "Tid",
"songCount": "Sanger",
"playCount": "Avspillinger",
"name": "Navn",
"genre": "Sjanger",
"compilation": "Samling",
"year": "År",
"updatedAt": "Oppdatert kl",
"comment": "Kommentar",
"rating": "Vurdering",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": "",
"recordLabel": "",
"catalogNum": "",
"releaseType": "",
"grouping": "",
"media": "",
"mood": ""
},
"actions": {
"playAll": "Spill",
"playNext": "Spill neste",
"addToQueue": "Spille senere",
"shuffle": "Bland",
"addToPlaylist": "Legg til i spilleliste",
"download": "nedlasting",
"info": "Få informasjon",
"share": ""
},
"lists": {
"all": "Alle",
"random": "Tilfeldig",
"recentlyAdded": "Nylig lagt til",
"recentlyPlayed": "Nylig spilt",
"mostPlayed": "Mest spilte",
"starred": "Favoritter",
"topRated": "Topp rangert"
}
},
"artist": {
"name": "Artist |||| Artister",
"fields": {
"name": "Navn",
"albumCount": "Antall album",
"songCount": "Antall sanger",
"playCount": "Spiller",
"rating": "Vurdering",
"genre": "Sjanger",
"size": "",
"role": ""
},
"roles": {
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"producer": "",
"director": "",
"engineer": "",
"mixer": "",
"remixer": "",
"djmixer": "",
"performer": ""
}
},
"user": {
"name": "Bruker |||| Brukere",
"fields": {
"userName": "Brukernavn",
"isAdmin": "er admin",
"lastLoginAt": "Siste pålogging kl",
"updatedAt": "Oppdatert kl",
"name": "Navn",
"password": "Passord",
"createdAt": "Opprettet kl",
"changePassword": "Bytte Passord",
"currentPassword": "Nåværende Passord",
"newPassword": "Nytt Passord",
"token": "Token",
"lastAccessAt": ""
},
"helperTexts": {
"name": "Endringer i navnet ditt vil kun gjenspeiles ved neste pålogging"
},
"notifications": {
"created": "Bruker opprettet",
"updated": "Bruker oppdatert",
"deleted": "Bruker fjernet"
},
"message": {
"listenBrainzToken": "Skriv inn ListenBrainz-brukertokenet ditt.",
"clickHereForToken": "Klikk her for å få tokenet ditt"
}
},
"player": {
"name": "Avspiller |||| Avspillere",
"fields": {
"name": "Navn",
"transcodingId": "Omkoding",
"maxBitRate": "Maks. Bithastighet",
"client": "Klient",
"userName": "Brukernavn",
"lastSeen": "Sist sett kl",
"reportRealPath": "Rapporter ekte sti",
"scrobbleEnabled": "Send Scrobbles til eksterne tjenester"
}
},
"transcoding": {
"name": "Omkoding |||| Omkodinger",
"fields": {
"name": "Navn",
"targetFormat": "Målformat",
"defaultBitRate": "Standard bithastighet",
"command": "Kommando"
}
},
"playlist": {
"name": "Spilleliste |||| Spillelister",
"fields": {
"name": "Navn",
"duration": "Varighet",
"ownerName": "Eieren",
"public": "Offentlig",
"updatedAt": "Oppdatert kl",
"createdAt": "Opprettet kl",
"songCount": "Sanger",
"comment": "Kommentar",
"sync": "Autoimport",
"path": "Import fra"
},
"actions": {
"selectPlaylist": "Velg en spilleliste:",
"addNewPlaylist": "Opprett \"%{name}\"",
"export": "Eksport",
"makePublic": "Gjør offentlig",
"makePrivate": "Gjør privat"
},
"message": {
"duplicate_song": "Legg til dupliserte sanger",
"song_exist": "Det legges til duplikater i spillelisten. Vil du legge til duplikatene eller hoppe over dem?"
}
},
"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": "Takk for at du installerte Navidrome!",
"welcome2": "Opprett en admin -bruker for å starte",
"confirmPassword": "Bekreft Passord",
"buttonCreateAdmin": "Opprett Admin",
"auth_check_error": "Vennligst Logg inn for å fortsette",
"user_menu": "Profil",
"username": "Brukernavn",
"password": "Passord",
"sign_in": "Logg inn",
"sign_in_error": "Autentisering mislyktes. Prøv på nytt",
"logout": "Logg ut",
"insightsCollectionNote": ""
},
"validation": {
"invalidChars": "Bruk bare bokstaver og tall",
"passwordDoesNotMatch": "Passordet er ikke like",
"required": "Obligatorisk",
"minLength": "Må være minst %{min} tegn",
"maxLength": "Må være %{max} tegn eller færre",
"minValue": "Må være minst %{min}",
"maxValue": "Må være %{max} eller mindre",
"number": "Må være et tall",
"email": "Må være en gyldig e-post",
"oneOf": "Må være en av: %{options}",
"regex": "Må samsvare med et spesifikt format (regexp): %{pattern}",
"unique": "Må være unik",
"url": ""
},
"action": {
"add_filter": "Legg til filter",
"add": "Legge til",
"back": "Gå tilbake",
"bulk_actions": "1 element valgt |||| %{smart_count} elementer er valgt",
"cancel": "Avbryt",
"clear_input_value": "Klar verdi",
"clone": "Klone",
"confirm": "Bekrefte",
"create": "Skape",
"delete": "Slett",
"edit": "Redigere",
"export": "Eksport",
"list": "Liste",
"refresh": "oppdater",
"remove_filter": "Fjern dette filteret",
"remove": "Fjerne",
"save": "Lagre",
"search": "Søk",
"show": "Vis",
"sort": "Sortere",
"undo": "Angre",
"expand": "Utvide",
"close": "Lukk",
"open_menu": "Åpne menyen",
"close_menu": "Lukk menyen",
"unselect": "Fjern valget",
"skip": "Hopp over",
"bulk_actions_mobile": "",
"share": "",
"download": ""
},
"boolean": {
"true": "Ja",
"false": "Nei"
},
"page": {
"create": "Opprett %{name}",
"dashboard": "Dashbord",
"edit": "%{name} #%{id}",
"error": "Noe gikk galt",
"list": "%{Navn}",
"loading": "Laster",
"not_found": "Ikke funnet",
"show": "%{name} #%{id}",
"empty": "Ingen %{name} ennå.",
"invite": "Vil du legge til en?"
},
"input": {
"file": {
"upload_several": "Slipp noen filer for å laste opp, eller klikk for å velge en.",
"upload_single": "Slipp en fil for å laste opp, eller klikk for å velge den."
},
"image": {
"upload_several": "Slipp noen bilder for å laste opp, eller klikk for å velge ett.",
"upload_single": "Slipp et bilde for å laste opp, eller klikk for å velge det."
},
"references": {
"all_missing": "Kan ikke finne referansedata.",
"many_missing": "Minst én av de tilknyttede referansene ser ikke ut til å være tilgjengelig lenger.",
"single_missing": "Tilknyttet referanse ser ikke lenger ut til å være tilgjengelig."
},
"password": {
"toggle_visible": "Skjul passord",
"toggle_hidden": "Vis passord"
}
},
"message": {
"about": "Om",
"are_you_sure": "Er du sikker?",
"bulk_delete_content": "Er du sikker på at du vil slette denne %{name}? |||| Er du sikker på at du vil slette disse %{smart_count} elementene?",
"bulk_delete_title": "Slett %{name} |||| Slett %{smart_count} %{name}",
"delete_content": "Er du sikker på at du vil slette dette elementet?",
"delete_title": "Slett %{name} #%{id}",
"details": "Detaljer",
"error": "Det oppstod en klientfeil og forespørselen din kunne ikke fullføres.",
"invalid_form": "Skjemaet er ikke gyldig. Vennligst se etter feil",
"loading": "Siden lastes, bare et øyeblikk",
"no": "Nei",
"not_found": "Enten skrev du inn feil URL, eller så fulgte du en dårlig lenke.",
"yes": "Ja",
"unsaved_changes": "Noen av endringene dine ble ikke lagret. Er du sikker på at du vil ignorere dem?"
},
"navigation": {
"no_results": "Ingen resultater",
"no_more_results": "Sidetallet %{page} er utenfor grensene. Prøv forrige side.",
"page_out_of_boundaries": "Sidetall %{page} utenfor grensene",
"page_out_from_end": "Kan ikke gå etter siste side",
"page_out_from_begin": "Kan ikke gå før side 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
"page_rows_per_page": "Elementer per side:",
"next": "Neste",
"prev": "Forrige",
"skip_nav": "Hopp til innholdet"
},
"notification": {
"updated": "Element oppdatert |||| %{smart_count} elementer er oppdatert",
"created": "Element opprettet",
"deleted": "Element slettet |||| %{smart_count} elementer slettet",
"bad_item": "Feil element",
"item_doesnt_exist": "Elementet eksisterer ikke",
"http_error": "Serverkommunikasjonsfeil",
"data_provider_error": "dataleverandørfeil. Sjekk konsollen for detaljer.",
"i18n_error": "Kan ikke laste oversettelsene for det angitte språket",
"canceled": "Handlingen avbrutt",
"logged_out": "Økten din er avsluttet. Koble til på nytt.",
"new_version": "Ny versjon tilgjengelig! Trykk Oppdater "
},
"toggleFieldsMenu": {
"columnsToDisplay": "Kolonner som skal vises",
"layout": "Oppsett",
"grid": "Nett",
"table": "Bord"
}
},
"message": {
"note": "Info",
"transcodingDisabled": "Endring av transkodingskonfigurasjonen gjennom webgrensesnittet er deaktivert av sikkerhetsgrunner. Hvis du ønsker å endre (redigere eller legge til) transkodingsalternativer, start serveren på nytt med %{config}-konfigurasjonsalternativet.",
"transcodingEnabled": "Navidrome kjører for øyeblikket med %{config}, noe som gjør det mulig å kjøre systemkommandoer fra transkodingsinnstillingene ved å bruke nettgrensesnittet. Vi anbefaler å deaktivere den av sikkerhetsgrunner og bare aktivere den når du konfigurerer alternativer for omkoding.",
"songsAddedToPlaylist": "Lagt til 1 sang i spillelisten |||| Lagt til %{smart_count} sanger i spillelisten",
"noPlaylistsAvailable": "Ingen tilgjengelig",
"delete_user_title": "Slett bruker «%{name}»",
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og alle dataene deres (inkludert spillelister og preferanser)?",
"notifications_blocked": "Du har blokkert varsler for dette nettstedet i nettleserens innstillinger",
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsvarsler, eller du har ikke tilgang til Navidrome over https",
"lastfmLinkSuccess": "Last.fm er vellykket koblet og scrobbling aktivert",
"lastfmLinkFailure": "Last.fm kunne ikke kobles til",
"lastfmUnlinkSuccess": "Last.fm koblet fra og scrobbling deaktivert",
"lastfmUnlinkFailure": "Last.fm kunne ikke kobles fra",
"openIn": {
"lastfm": "Åpne i Last.fm",
"musicbrainz": "Åpne i MusicBrainz"
},
"lastfmLink": "Les mer...",
"listenBrainzLinkSuccess": "ListenBrainz er vellykket koblet og scrobbling aktivert som bruker: %{user}",
"listenBrainzLinkFailure": "ListenBrainz kunne ikke kobles: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz koblet fra og scrobbling deaktivert",
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke fjernes",
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": "",
"remove_missing_title": "",
"remove_missing_content": ""
},
"menu": {
"library": "Bibliotek",
"settings": "Innstillinger",
"version": "Versjon",
"theme": "Tema",
"personal": {
"name": "Personlig",
"options": {
"theme": "Tema",
"language": "Språk",
"defaultView": "Standardvisning",
"desktop_notifications": "Skrivebordsvarsler",
"lastfmScrobbling": "Scrobble til Last.fm",
"listenBrainzScrobbling": "Scrobble til ListenBrainz",
"replaygain": "",
"preAmp": "",
"gain": {
"none": "",
"album": "",
"track": ""
},
"lastfmNotConfigured": ""
}
},
"albumList": "Album",
"about": "Om",
"playlists": "Spilleliste",
"sharedPlaylists": "Delte spillelister"
},
"player": {
"playListsText": "Spillekø",
"openText": "Åpne",
"closeText": "Lukk",
"notContentText": "Ingen musikk",
"clickToPlayText": "Klikk for å spille",
"clickToPauseText": "Klikk for å sette på pause",
"nextTrackText": "Neste spor",
"previousTrackText": "Forrige spor",
"reloadText": "Last inn på nytt",
"volumeText": "Volum",
"toggleLyricText": "Veksle mellom tekster",
"toggleMiniModeText": "Minimer",
"destroyText": "Ødelegge",
"downloadText": "nedlasting",
"removeAudioListsText": "Slett lydlister",
"clickToDeleteText": "Klikk for å slette %{name}",
"emptyLyricText": "Ingen sangtekster",
"playModeText": {
"order": "I rekkefølge",
"orderLoop": "Gjenta",
"singleLoop": "Gjenta engang",
"shufflePlay": "Tilfeldig rekkefølge"
}
},
"about": {
"links": {
"homepage": "Hjemmeside",
"source": "Kildekode",
"featureRequests": "Funksjonsforespørsler",
"lastInsightsCollection": "",
"insights": {
"disabled": "",
"waiting": ""
}
}
},
"activity": {
"title": "Aktivitet",
"totalScanned": "Totalt skannede mapper",
"quickScan": "Rask skanning",
"fullScan": "Full skanning",
"serverUptime": "Serveroppetid",
"serverDown": "OFFLINE"
},
"help": {
"title": "Navidrome hurtigtaster",
"hotkeys": {
"show_help": "Vis denne hjelpen",
"toggle_menu": "Bytt menysidelinje",
"toggle_play": "Spill / Pause",
"prev_song": "Forrige sang",
"next_song": "Neste sang",
"vol_up": "Volum opp",
"vol_down": "Volum ned",
"toggle_love": "Legg til dette sporet i favoritter",
"current_song": ""
}
}
}

View File

@@ -26,7 +26,14 @@
"bpm": "BPM",
"playDate": "Ostatnio Odtwarzane",
"channels": "Kanały",
"createdAt": "Data dodania"
"createdAt": "Data dodania",
"grouping": "Grupowanie",
"mood": "Nastrój",
"participants": "Dodatkowi uczestnicy",
"tags": "Dodatkowe Tagi",
"mappedTags": "Zmapowane tagi",
"rawTags": "Surowe tagi",
"bitDepth": "Głębokość próbkowania"
},
"actions": {
"addToQueue": "Odtwarzaj Później",
@@ -58,7 +65,14 @@
"originalDate": "Pierwotna Data",
"releaseDate": "Data Wydania",
"releases": "Wydanie |||| Wydania",
"released": "Wydany"
"released": "Wydany",
"recordLabel": "Wytwórnia",
"catalogNum": "Numer Katalogowy",
"releaseType": "Typ",
"grouping": "Grupowanie",
"media": "Media",
"mood": "Nastrój",
"date": ""
},
"actions": {
"playAll": "Odtwarzaj",
@@ -89,7 +103,23 @@
"playCount": "Liczba Odtworzeń",
"rating": "Ocena",
"genre": "Gatunek",
"size": "Rozmiar"
"size": "Rozmiar",
"role": "Rola"
},
"roles": {
"albumartist": "Wykonawca Albumu |||| Wykonawcy Albumu",
"artist": "Wykonawca |||| Wykonawcy",
"composer": "Kompozytor |||| Kompozytorzy",
"conductor": "Dyrygent |||| Dyrygenci",
"lyricist": "Autor tekstów |||| Autorzy tekstów",
"arranger": "Aranżer |||| Aranżerzy",
"producer": "Producent |||| Producenci",
"director": "Reżyser |||| Reżyserzy",
"engineer": "Inżynier |||| Inżynierowie",
"mixer": "Mikser |||| Mikserzy",
"remixer": "Remixer |||| Remixerzy",
"djmixer": "Didżej |||| Didżerzy",
"performer": "Wykonawca |||| Wykonawcy"
}
},
"user": {
@@ -198,6 +228,21 @@
"createdAt": "Stworzono",
"downloadable": "Zezwolić Na Pobieranie?"
}
},
"missing": {
"name": "Brakujący Plik|||| Brakujące Pliki",
"fields": {
"path": "Ścieżka",
"size": "Rozmiar",
"updatedAt": "Zniknął na"
},
"actions": {
"remove": "Usuń"
},
"notifications": {
"removed": "Usunięto brakujące pliki"
},
"empty": "Bez Brakujących Plików"
}
},
"ra": {
@@ -375,7 +420,9 @@
"shareSuccess": "Adres URL skopiowany do schowka: %{url}",
"shareFailure": "Błąd podczas kopiowania URL %{url} do schowka",
"downloadDialogTitle": "Pobierz %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Skopiuj do schowka: Ctrl+C, Enter"
"shareCopyToClipboard": "Skopiuj do schowka: Ctrl+C, Enter",
"remove_missing_title": "Usuń brakujące dane",
"remove_missing_content": "Czy na pewno chcesz usunąć wybrane brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszystkich powiązań, takich jak liczba odtworzeń i oceny."
},
"menu": {
"library": "Biblioteka",

View File

@@ -18,6 +18,7 @@
"size": "Tamanho",
"updatedAt": "Últ. Atualização",
"bitRate": "Bitrate",
"bitDepth": "Profundidade de bits",
"discSubtitle": "Sub-título do disco",
"starred": "Favorita",
"comment": "Comentário",
@@ -56,6 +57,7 @@
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano",
"date": "Data de Lançamento",
"updatedAt": "Últ. Atualização",
"comment": "Comentário",
"rating": "Classificação",
@@ -229,6 +231,7 @@
},
"missing": {
"name": "Arquivo ausente |||| Arquivos ausentes",
"empty": "Nenhum arquivo ausente",
"fields": {
"path": "Caminho",
"size": "Tamanho",

View File

@@ -32,7 +32,8 @@
"participants": "Дополнительные участники",
"tags": "Дополнительные теги",
"mappedTags": "Сопоставленные теги",
"rawTags": "Исходные теги"
"rawTags": "Исходные теги",
"bitDepth": "Битовая глубина"
},
"actions": {
"addToQueue": "В очередь",
@@ -70,7 +71,8 @@
"releaseType": "Тип",
"grouping": "Группирование",
"media": "Медиа",
"mood": "Настроение"
"mood": "Настроение",
"date": ""
},
"actions": {
"playAll": "Играть",
@@ -239,7 +241,8 @@
},
"notifications": {
"removed": "Отсутствующие файлы удалены"
}
},
"empty": "Нет отсутствующих файлов"
}
},
"ra": {

View File

@@ -32,7 +32,8 @@
"participants": "Ek katılımcılar",
"tags": "Ek Etiketler",
"mappedTags": "Eşlenen etiketler",
"rawTags": "Ham etiketler"
"rawTags": "Ham etiketler",
"bitDepth": "Bit derinliği"
},
"actions": {
"addToQueue": "Oynatma Sırasına Ekle",
@@ -70,7 +71,8 @@
"releaseType": "Tür",
"grouping": "Gruplama",
"media": "Medya",
"mood": "Mod"
"mood": "Mod",
"date": "Kayıt Tarihi"
},
"actions": {
"playAll": "Oynat",
@@ -239,7 +241,8 @@
},
"notifications": {
"removed": "Eksik dosya(lar) kaldırıldı"
}
},
"empty": "Eksik Dosya Yok"
}
},
"ra": {

View File

@@ -118,10 +118,10 @@ main:
aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear ]
type: date
recordingdate:
aliases: [ tdrc, date, icrd, ©day, wm/year, year ]
aliases: [ tdrc, date, recordingdate, icrd, record date ]
type: date
releasedate:
aliases: [ tdrl, releasedate ]
aliases: [ tdrl, releasedate, ©day, wm/year, year ]
type: date
catalognumber:
aliases: [ txxx:catalognumber, catalognumber, ----:com.apple.itunes:catalognumber, wm/catalogno ]

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

View File

@@ -98,7 +98,6 @@ type ProgressInfo struct {
type scanner interface {
scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo)
// BFR: scanFolders(ctx context.Context, lib model.Lib, folders []string, progress chan<- *ScannerStatus)
}
type controller struct {

View File

@@ -33,6 +33,8 @@ func (s *scannerExternal) scanAll(ctx context.Context, fullScan bool, progress c
cmd := exec.CommandContext(ctx, exe, "scan",
"--nobanner", "--subprocess",
"--configfile", conf.Server.ConfigFile,
"--datafolder", conf.Server.DataFolder,
"--cachefolder", conf.Server.CacheFolder,
If(fullScan, "--full", ""))
in, out := io.Pipe()

View File

@@ -150,6 +150,14 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
Path: folder.path,
Phase: "1",
})
// Log folder info
log.Trace(p.ctx, "Scanner: Checking folder state", " folder", folder.path, "_updTime", folder.updTime,
"_modTime", folder.modTime, "_lastScanStartedAt", folder.job.lib.LastScanStartedAt,
"numAudioFiles", len(folder.audioFiles), "numImageFiles", len(folder.imageFiles),
"numPlaylists", folder.numPlaylists, "numSubfolders", folder.numSubFolders)
// Check if folder is outdated
if folder.isOutdated() {
if !p.state.fullScan {
if folder.hasNoFiles() && folder.isNew() {
@@ -161,6 +169,8 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
totalChanged++
folder.elapsed.Stop()
put(folder)
} else {
log.Trace(p.ctx, "Scanner: Skipping up-to-date folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name)
}
}
total += job.numFolders.Load()

View File

@@ -45,8 +45,12 @@ func (p *phasePlaylists) producer() ppl.Producer[*model.Folder] {
}
func (p *phasePlaylists) produce(put func(entry *model.Folder)) error {
if !conf.Server.AutoImportPlaylists {
log.Info(p.ctx, "Playlists will not be imported, AutoImportPlaylists is set to false")
return nil
}
u, _ := request.UserFrom(p.ctx)
if !conf.Server.AutoImportPlaylists || !u.IsAdmin {
if !u.IsAdmin {
log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+
"Please create an admin user first, and then update the playlists for them to be imported")
return nil

View File

@@ -70,7 +70,6 @@ func newFolderEntry(job *scanJob, path string) *folderEntry {
albumIDMap: make(map[string]string),
updTime: job.popLastUpdate(id),
}
f.elapsed.Start()
return f
}
@@ -115,6 +114,8 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignoreP
"images", maps.Keys(folder.imageFiles), "playlists", folder.numPlaylists, "imagesUpdatedAt", folder.imagesUpdatedAt,
"updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children))
folder.path = dir
folder.elapsed.Start()
results <- folder
return nil

View File

@@ -292,13 +292,17 @@ func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]inte
user, err := userRepo.FindByUsernameWithPassword(username)
if user == nil || err != nil {
log.Info(r, "User passed in header not found", "user", username)
// Check if this is the first user being created
count, _ := userRepo.CountAll()
isFirstUser := count == 0
newUser := model.User{
ID: id.NewRandom(),
UserName: username,
Name: username,
Email: "",
NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(),
IsAdmin: false,
IsAdmin: isFirstUser, // Make the first user an admin
}
err := userRepo.Put(&newUser)
if err != nil {

View File

@@ -292,4 +292,54 @@ var _ = Describe("Auth", func() {
})
})
})
Describe("handleLoginFromHeaders", func() {
var ds model.DataStore
var req *http.Request
const trustedIP = "192.168.0.42"
BeforeEach(func() {
ds = &tests.MockDataStore{}
req = httptest.NewRequest("GET", "/", nil)
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16"
conf.Server.ReverseProxyUserHeader = "Remote-User"
})
It("makes the first user an admin", func() {
// No existing users
req.Header.Set("Remote-User", "firstuser")
result := handleLoginFromHeaders(ds, req)
Expect(result).ToNot(BeNil())
Expect(result["isAdmin"]).To(BeTrue())
// Verify user was created as admin
u, err := ds.User(context.Background()).FindByUsername("firstuser")
Expect(err).To(BeNil())
Expect(u.IsAdmin).To(BeTrue())
})
It("does not make subsequent users admins", func() {
// Create the first user
_ = ds.User(context.Background()).Put(&model.User{
ID: "existing-user-id",
UserName: "existinguser",
Name: "Existing User",
IsAdmin: true,
})
// Try to create a second user via proxy header
req.Header.Set("Remote-User", "seconduser")
result := handleLoginFromHeaders(ds, req)
Expect(result).ToNot(BeNil())
Expect(result["isAdmin"]).To(BeFalse())
// Verify user was created as non-admin
u, err := ds.User(context.Background()).FindByUsername("seconduser")
Expect(err).To(BeNil())
Expect(u.IsAdmin).To(BeFalse())
})
})
})

View File

@@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
@@ -30,37 +31,37 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic,
type Router struct {
http.Handler
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
externalMetadata core.ExternalMetadata
playlists core.Playlists
scanner scanner.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
provider external.Provider
playlists core.Playlists
scanner scanner.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
players core.Players, externalMetadata core.ExternalMetadata, scanner scanner.Scanner, broker events.Broker,
players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
) *Router {
r := &Router{
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
externalMetadata: externalMetadata,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
provider: provider,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
}
r.Handler = r.routes()
return r

View File

@@ -210,7 +210,7 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) {
return nil, err
}
album, err := api.externalMetadata.UpdateAlbumInfo(ctx, id)
album, err := api.provider.UpdateAlbumInfo(ctx, id)
if err != nil {
return nil, err
@@ -252,9 +252,7 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) {
func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
// TODO Put back when album_count is available
//genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"})
genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, name desc", Order: "desc"})
genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"})
if err != nil {
log.Error(r, err)
return nil, err
@@ -280,7 +278,7 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m
count := p.IntOr("count", 20)
includeNotPresent := p.BoolOr("includeNotPresent", false)
artist, err := api.externalMetadata.UpdateArtistInfo(ctx, id, count, includeNotPresent)
artist, err := api.provider.UpdateArtistInfo(ctx, id, count, includeNotPresent)
if err != nil {
return nil, nil, err
}
@@ -345,7 +343,7 @@ func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error)
}
count := p.IntOr("count", 50)
songs, err := api.externalMetadata.SimilarSongs(ctx, id, count)
songs, err := api.provider.SimilarSongs(ctx, id, count)
if err != nil {
return nil, err
}
@@ -379,8 +377,8 @@ func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) {
}
count := p.IntOr("count", 50)
songs, err := api.externalMetadata.TopSongs(ctx, artist, count)
if err != nil {
songs, err := api.provider.TopSongs(ctx, artist, count)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, err
}
@@ -424,7 +422,7 @@ func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*response
return nil, err
}
a.Album = slice.MapWithArg(albums, ctx, childFromAlbum)
a.Album = slice.MapWithArg(albums, ctx, buildAlbumID3)
return a, nil
}

View File

@@ -48,11 +48,11 @@ func AlbumsByArtist() Options {
func AlbumsByArtistID(artistId string) Options {
filters := []Sqlizer{
persistence.Exists("json_tree(Participants, '$.albumartist')", Eq{"value": artistId}),
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}),
}
if conf.Server.Subsonic.ArtistParticipations {
filters = append(filters,
persistence.Exists("json_tree(Participants, '$.artist')", Eq{"value": artistId}),
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}),
)
}
return addDefaultFilters(Options{
@@ -62,13 +62,14 @@ func AlbumsByArtistID(artistId string) Options {
}
func AlbumsByYear(fromYear, toYear int) Options {
sortOption := "max_year, name"
orderOption := ""
if fromYear > toYear {
fromYear, toYear = toYear, fromYear
sortOption = "max_year desc, name"
orderOption = "desc"
}
return addDefaultFilters(Options{
Sort: sortOption,
Sort: "max_year",
Order: orderOption,
Filters: Or{
And{
GtOrEq{"min_year": fromYear},
@@ -118,7 +119,7 @@ func SongWithLyrics(artist, title string) Options {
func ByGenre(genre string) Options {
return addDefaultFilters(Options{
Sort: "name asc",
Sort: "name",
Filters: filterByGenre(genre),
})
}

View File

@@ -235,13 +235,12 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op
child.BitDepth = int32(mf.BitDepth)
child.Genres = toItemGenres(mf.Genres)
child.Moods = mf.Tags.Values(model.TagMood)
// BFR What if Child is an Album and not a Song?
child.DisplayArtist = mf.Artist
child.Artists = artistRefs(mf.Participants[model.RoleArtist])
child.DisplayAlbumArtist = mf.AlbumArtist
child.AlbumArtists = artistRefs(mf.Participants[model.RoleAlbumArtist])
var contributors []responses.Contributor
child.DisplayComposer = mf.Participants[model.RoleComposer].Join(" • ")
child.DisplayComposer = mf.Participants[model.RoleComposer].Join(consts.ArtistJoiner)
for role, participants := range mf.Participants {
if role == model.RoleArtist || role == model.RoleAlbumArtist {
continue
@@ -297,7 +296,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
child.Name = al.Name
child.Album = al.Name
child.Artist = al.AlbumArtist
child.Year = int32(al.MaxYear)
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
child.Genre = al.Genre
child.CoverArt = al.CoverArtID().String()
child.Created = &al.CreatedAt
@@ -381,7 +380,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
dir.SongCount = int32(album.SongCount)
dir.Duration = int32(album.Duration)
dir.PlayCount = album.PlayCount
dir.Year = int32(album.MaxYear)
dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear))
dir.Genre = album.Genre
if !album.CreatedAt.IsZero() {
dir.Created = &album.CreatedAt

View File

@@ -1,8 +1,8 @@
{
"status": "ok",
"version": "1.8.0",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.0.0",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"albumInfo": {
"notes": "Believe is the twenty-third studio album by American singer-actress Cher...",

View File

@@ -1,4 +1,4 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumInfo>
<notes>Believe is the twenty-third studio album by American singer-actress Cher...</notes>
<musicBrainzId>03c91c40-49a6-44a7-90e7-a700edf97a62</musicBrainzId>

View File

@@ -1,8 +1,8 @@
{
"status": "ok",
"version": "1.8.0",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.0.0",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"albumInfo": {}
}

View File

@@ -1,3 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumInfo></albumInfo>
</subsonic-response>

View File

@@ -1,8 +1,8 @@
{
"status": "ok",
"version": "1.8.0",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.0.0",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"albumList": {
"album": [

View File

@@ -1,4 +1,4 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumList>
<album id="1" isDir="false" isVideo="false" sortName="sort name" mediaType="album" musicBrainzId="00000000-0000-0000-0000-000000000000" displayArtist="Display artist" displayAlbumArtist="Display album artist" explicitStatus="explicit">
<genres name="Genre 1"></genres>

View File

@@ -1,8 +1,8 @@
{
"status": "ok",
"version": "1.8.0",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.0.0",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"albumList": {
"album": [

View File

@@ -1,4 +1,4 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumList>
<album id="1" isDir="false" title="title" isVideo="false"></album>
</albumList>

View File

@@ -1,8 +1,8 @@
{
"status": "ok",
"version": "1.8.0",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.0.0",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"albumList": {}
}

View File

@@ -1,3 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumList></albumList>
</subsonic-response>

View File

@@ -1,8 +1,8 @@
{
"status": "ok",
"version": "1.8.0",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.0.0",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"album": {
"id": "1",

View File

@@ -1,4 +1,4 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<album id="1" name="album" artist="artist" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 &amp; artist2" explicitStatus="clean" version="Deluxe Edition">
<genres name="rock"></genres>
<genres name="progressive"></genres>

Some files were not shown because too many files have changed in this diff Show More