mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-01 03:18:13 -05:00
Compare commits
68 Commits
plugins-mc
...
v0.56.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b19d5f0d3e | ||
|
|
175964b17a | ||
|
|
90b095b409 | ||
|
|
821f485022 | ||
|
|
d4a053370a | ||
|
|
66926ca466 | ||
|
|
1f9cbe7345 | ||
|
|
de698918ac | ||
|
|
71851b076c | ||
|
|
85a7268192 | ||
|
|
9dd5a8c334 | ||
|
|
030710afa9 | ||
|
|
5050250902 | ||
|
|
fb32cfd7db | ||
|
|
d26e2e29a6 | ||
|
|
5c4fbdb7c1 | ||
|
|
0cb02bce06 | ||
|
|
fe1ed582bc | ||
|
|
5e2db2c673 | ||
|
|
fac9275c27 | ||
|
|
6b3afc03cc | ||
|
|
35599230ff | ||
|
|
13ea00e7f8 | ||
|
|
f7fb77054f | ||
|
|
441c9f52cc | ||
|
|
b722f0dcfc | ||
|
|
c98e4d02cb | ||
|
|
5ade9344ff | ||
|
|
d903d3f1e0 | ||
|
|
6bf6424864 | ||
|
|
a9f93c97e1 | ||
|
|
3350e6c115 | ||
|
|
514aceb785 | ||
|
|
370f8ba293 | ||
|
|
1e4c759d93 | ||
|
|
e06fbd26b7 | ||
|
|
9062f4824e | ||
|
|
2503d2dbb8 | ||
|
|
45188e710c | ||
|
|
9dd050c377 | ||
|
|
3ccc02f375 | ||
|
|
992c78376c | ||
|
|
4a2412eef7 | ||
|
|
98fdc42d09 | ||
|
|
eb944bd261 | ||
|
|
84384006a4 | ||
|
|
e5438552c6 | ||
|
|
6ac3acaaf8 | ||
|
|
3953e3217d | ||
|
|
6731787053 | ||
|
|
dd1d3907b4 | ||
|
|
924354eb4b | ||
|
|
6880cffd16 | ||
|
|
fef1739c1a | ||
|
|
453630d430 | ||
|
|
4733616d90 | ||
|
|
ba7fd13724 | ||
|
|
1e4e3eac6e | ||
|
|
19d443ec7f | ||
|
|
db92cf9e47 | ||
|
|
ec9f9aa243 | ||
|
|
0d1f2bcc8a | ||
|
|
dfa217ab51 | ||
|
|
3d6a2380bc | ||
|
|
53aa640f35 | ||
|
|
e4d65a7828 | ||
|
|
b41123f75e | ||
|
|
6f52c0201c |
53
.github/copilot-instructions.md
vendored
Normal file
53
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
# Navidrome Code Guidelines
|
||||
|
||||
This is a music streaming server written in Go with a React frontend. The application manages music libraries, provides streaming capabilities, and offers various features like artist information, artwork handling, and external service integrations.
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (Go)
|
||||
- Follow standard Go conventions and idioms
|
||||
- Use context propagation for cancellation signals
|
||||
- Write unit tests for new functionality using Ginkgo/Gomega
|
||||
- Use mutex appropriately for concurrent operations
|
||||
- Implement interfaces for dependencies to facilitate testing
|
||||
|
||||
### Frontend (React)
|
||||
- Use functional components with hooks
|
||||
- Follow React best practices for state management
|
||||
- Implement PropTypes for component properties
|
||||
- Prefer using React-Admin and Material-UI components
|
||||
- Icons should be imported from `react-icons` only
|
||||
- Follow existing patterns for API interaction
|
||||
|
||||
## Repository Structure
|
||||
- `core/`: Server-side business logic (artwork handling, playback, etc.)
|
||||
- `ui/`: React frontend components
|
||||
- `model/`: Data models and repository interfaces
|
||||
- `server/`: API endpoints and server implementation
|
||||
- `utils/`: Shared utility functions
|
||||
- `persistence/`: Database access layer
|
||||
- `scanner/`: Music library scanning functionality
|
||||
|
||||
## Key Guidelines
|
||||
1. Maintain cache management patterns for performance
|
||||
2. Follow the existing concurrency patterns (mutex, atomic)
|
||||
3. Use the testing framework appropriately (Ginkgo/Gomega for Go)
|
||||
4. Keep UI components focused and reusable
|
||||
5. Document configuration options in code
|
||||
6. Consider performance implications when working with music libraries
|
||||
7. Follow existing error handling patterns
|
||||
8. Ensure compatibility with external services (LastFM, Spotify)
|
||||
|
||||
## Development Workflow
|
||||
- Test changes thoroughly, especially around concurrent operations
|
||||
- Validate both backend and frontend interactions
|
||||
- Consider how changes will affect user experience and performance
|
||||
- Test with different music library sizes and configurations
|
||||
- Before committing, ALWAYS run `make format lint test`, and make sure there are no issues
|
||||
|
||||
## Important commands
|
||||
- `make build`: Build the application
|
||||
- `make test`: Run Go tests
|
||||
- To run tests for a specific package, use `make test PKG=./pkgname/...`
|
||||
- `make lintall`: Run linters
|
||||
- `make format`: Format code
|
||||
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,4 +24,6 @@ docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
binaries
|
||||
navidrome-master
|
||||
*.exe
|
||||
AGENTS.md
|
||||
*.exe
|
||||
bin/
|
||||
23
Makefile
23
Makefile
@@ -19,7 +19,7 @@ CROSS_TAGLIB_VERSION ?= 2.0.2-1
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
@echo Downloading Node dependencies...
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
@@ -36,8 +36,9 @@ watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go tool ginkgo watch -tags=netgo -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
PKG ?= ./...
|
||||
test: ##@Development Run Go tests
|
||||
go test -tags netgo ./...
|
||||
go test -tags netgo $(PKG)
|
||||
.PHONY: test
|
||||
|
||||
testrace: ##@Development Run Go tests with race detector
|
||||
@@ -45,11 +46,15 @@ testrace: ##@Development Run Go tests with race detector
|
||||
.PHONY: test
|
||||
|
||||
testall: testrace ##@Development Run Go and JS tests
|
||||
@(cd ./ui && npm run test:ci)
|
||||
@(cd ./ui && npm run test)
|
||||
.PHONY: testall
|
||||
|
||||
lint: ##@Development Lint Go code
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
@PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
@@ -156,10 +161,10 @@ package: docker-build ##@Cross_Compilation Create binaries and packages for ALL
|
||||
get-music: ##@Development Download some free music from Navidrome's demo instance
|
||||
mkdir -p music
|
||||
( cd music; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=2Y3qQA6zJC3ObbBrF9ZBoV" > brock.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=04HrSORpypcLGNUdQp37gn" > back_on_earth.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=5xcMPJdeEgNrGtnzYbzAqb" > ugress.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=1jjQMAZrG3lUsJ0YH6ZRS0" > voodoocuts.zip; \
|
||||
for file in *.zip; do unzip -n $${file}; done )
|
||||
@echo "Done. Remember to set your MusicFolder to ./music"
|
||||
.PHONY: get-music
|
||||
|
||||
@@ -82,15 +82,15 @@ var _ = Describe("Extractor", func() {
|
||||
|
||||
// TabLib 1.12 returns 18, previous versions return 39.
|
||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 39, 40, 43, 49))
|
||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.HasPicture).To(BeFalse())
|
||||
Expect(m.HasPicture).To(BeTrue())
|
||||
})
|
||||
|
||||
DescribeTable("Format-Specific tests",
|
||||
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool) {
|
||||
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
|
||||
file = "tests/fixtures/" + file
|
||||
mds, err := e.Parse(file)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
@@ -98,7 +98,7 @@ var _ = Describe("Extractor", func() {
|
||||
|
||||
m := mds[file]
|
||||
|
||||
Expect(m.HasPicture).To(BeFalse())
|
||||
Expect(m.HasPicture).To(Equal(image))
|
||||
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
|
||||
Expect(m.AudioProperties.Channels).To(Equal(channels))
|
||||
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
|
||||
@@ -168,24 +168,24 @@ var _ = Describe("Extractor", func() {
|
||||
},
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
|
||||
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false),
|
||||
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
|
||||
|
||||
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false),
|
||||
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false),
|
||||
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false),
|
||||
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
||||
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
|
||||
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false),
|
||||
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
|
||||
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false),
|
||||
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, false),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
|
||||
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true),
|
||||
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
|
||||
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true),
|
||||
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
|
||||
)
|
||||
|
||||
// Skip these tests when running as root
|
||||
|
||||
@@ -225,18 +225,25 @@ char has_cover(const TagLib::FileRef f) {
|
||||
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
|
||||
hasCover = !opusFile->tag()->pictureList().isEmpty();
|
||||
}
|
||||
// ----- WMA
|
||||
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();
|
||||
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- AIFF
|
||||
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
|
||||
if (aiffFile->hasID3v2Tag()) {
|
||||
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- WMA
|
||||
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
|
||||
const TagLib::ASF::Tag *tag{ asfFile->tag() };
|
||||
hasCover = tag && asfFile->tag()->attributeListMap().contains("WM/Picture");
|
||||
}
|
||||
|
||||
return hasCover;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ type configOptions struct {
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
@@ -93,6 +94,7 @@ type configOptions struct {
|
||||
PID pidOptions
|
||||
Inspect inspectOptions
|
||||
Subsonic subsonicOptions
|
||||
LyricsPriority string
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
@@ -109,7 +111,6 @@ type configOptions struct {
|
||||
DevActivityPanel bool
|
||||
DevActivityPanelUpdateRate time.Duration
|
||||
DevSidebarPlaylists bool
|
||||
DevEnableBufferedScrobble bool
|
||||
DevShowArtistPage bool
|
||||
DevOffsetOptimize int
|
||||
DevArtworkMaxRequests int
|
||||
@@ -132,6 +133,8 @@ type scannerOptions struct {
|
||||
ArtistJoiner string
|
||||
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
||||
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
||||
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
||||
PurgeMissing string // Values: "never", "always", "full"
|
||||
}
|
||||
|
||||
type subsonicOptions struct {
|
||||
@@ -151,10 +154,11 @@ type TagConf struct {
|
||||
}
|
||||
|
||||
type lastfmOptions struct {
|
||||
Enabled bool
|
||||
ApiKey string
|
||||
Secret string
|
||||
Language string
|
||||
Enabled bool
|
||||
ApiKey string
|
||||
Secret string
|
||||
Language string
|
||||
ScrobbleFirstArtistOnly bool
|
||||
}
|
||||
|
||||
type spotifyOptions struct {
|
||||
@@ -275,6 +279,7 @@ func Load(noConfigDump bool) {
|
||||
validateScanSchedule,
|
||||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
@@ -312,6 +317,7 @@ func Load(noConfigDump bool) {
|
||||
}
|
||||
logDeprecatedOptions("Scanner.GenreSeparators")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
@@ -379,6 +385,24 @@ func validatePlaylistsPath() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePurgeMissingOption() error {
|
||||
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
||||
valid := false
|
||||
for _, v := range allowedValues {
|
||||
if v == Server.Scanner.PurgeMissing {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
||||
log.Error(err.Error())
|
||||
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateScanSchedule() error {
|
||||
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
||||
Server.Scanner.Schedule = ""
|
||||
@@ -418,7 +442,7 @@ func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
}
|
||||
|
||||
func init() {
|
||||
func setViperDefaults() {
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("cachefolder", "")
|
||||
viper.SetDefault("datafolder", ".")
|
||||
@@ -455,7 +479,6 @@ func init() {
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
|
||||
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
@@ -470,6 +493,7 @@ func init() {
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enableinsightscollector", true)
|
||||
@@ -477,19 +501,15 @@ func init() {
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
viper.SetDefault("passwordencryptionkey", "")
|
||||
|
||||
viper.SetDefault("reverseproxyuserheader", "Remote-User")
|
||||
viper.SetDefault("reverseproxywhitelist", "")
|
||||
|
||||
viper.SetDefault("prometheus.enabled", false)
|
||||
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
||||
viper.SetDefault("prometheus.password", "")
|
||||
|
||||
viper.SetDefault("jukebox.enabled", false)
|
||||
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
||||
viper.SetDefault("jukebox.default", "")
|
||||
viper.SetDefault("jukebox.adminonly", true)
|
||||
|
||||
viper.SetDefault("scanner.enabled", true)
|
||||
viper.SetDefault("scanner.schedule", "0")
|
||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||
@@ -498,44 +518,39 @@ func init() {
|
||||
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
|
||||
viper.SetDefault("scanner.genreseparators", "")
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
|
||||
viper.SetDefault("scanner.followsymlinks", true)
|
||||
viper.SetDefault("scanner.purgemissing", "never")
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
|
||||
viper.SetDefault("agents", "lastfm,spotify")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
viper.SetDefault("lastfm.secret", "")
|
||||
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
|
||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||
|
||||
viper.SetDefault("backup.path", "")
|
||||
viper.SetDefault("backup.schedule", "")
|
||||
viper.SetDefault("backup.count", 0)
|
||||
|
||||
viper.SetDefault("pid.track", consts.DefaultTrackPID)
|
||||
viper.SetDefault("pid.album", consts.DefaultAlbumPID)
|
||||
|
||||
viper.SetDefault("inspect.enabled", true)
|
||||
viper.SetDefault("inspect.maxrequests", 1)
|
||||
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devenableprofiler", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
viper.SetDefault("devautologinusername", "")
|
||||
viper.SetDefault("devactivitypanel", true)
|
||||
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
|
||||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
@@ -550,6 +565,10 @@ func init() {
|
||||
viper.SetDefault("devenableplayerinsights", true)
|
||||
}
|
||||
|
||||
func init() {
|
||||
setViperDefaults()
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
codecRegistry := viper.NewCodecRegistry()
|
||||
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
. "github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/spf13/viper"
|
||||
@@ -20,9 +20,10 @@ var _ = Describe("Configuration", func() {
|
||||
BeforeEach(func() {
|
||||
// Reset viper configuration
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
ResetConf()
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
@@ -30,17 +31,17 @@ var _ = Describe("Configuration", func() {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
// Initialize config with the test file
|
||||
InitConfig(filename)
|
||||
conf.InitConfig(filename)
|
||||
// Load the configuration (with noConfigDump=true)
|
||||
Load(true)
|
||||
conf.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"}))
|
||||
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
|
||||
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
|
||||
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
||||
|
||||
// The config file used should be the one we created
|
||||
Expect(Server.ConfigFile).To(Equal(filename))
|
||||
Expect(conf.Server.ConfigFile).To(Equal(filename))
|
||||
},
|
||||
Entry("TOML format", "toml"),
|
||||
Entry("YAML format", "yaml"),
|
||||
|
||||
@@ -3,3 +3,5 @@ package conf
|
||||
func ResetConf() {
|
||||
Server = &configOptions{}
|
||||
}
|
||||
|
||||
var SetViperDefaults = setViperDefaults
|
||||
|
||||
@@ -14,6 +14,9 @@ const (
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
|
||||
LastScanErrorKey = "LastScanError"
|
||||
LastScanTypeKey = "LastScanType"
|
||||
LastScanStartTimeKey = "LastScanStartTime"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
@@ -112,6 +115,12 @@ const (
|
||||
InsightsInitialDelay = 30 * time.Minute
|
||||
)
|
||||
|
||||
const (
|
||||
PurgeMissingNever = "never"
|
||||
PurgeMissingAlways = "always"
|
||||
PurgeMissingFull = "full"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultDownsamplingFormat = "opus"
|
||||
DefaultTranscodings = []struct {
|
||||
|
||||
@@ -45,7 +45,7 @@ func createAgents(ds model.DataStore) *Agents {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, name)
|
||||
res = append(res, agent)
|
||||
res = append(res, init(ds))
|
||||
}
|
||||
log.Debug("List of agents enabled", "names", enabled)
|
||||
|
||||
|
||||
@@ -279,6 +279,13 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
|
||||
return t.Track, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
|
||||
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
|
||||
return track.Participants[model.RoleArtist][0].Name
|
||||
}
|
||||
return track.Artist
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
@@ -286,7 +293,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
|
||||
}
|
||||
|
||||
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
|
||||
artist: track.Artist,
|
||||
artist: l.getArtistForScrobble(track),
|
||||
track: track.Title,
|
||||
album: track.Album,
|
||||
trackNumber: track.TrackNumber,
|
||||
@@ -312,7 +319,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
||||
return nil
|
||||
}
|
||||
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
|
||||
artist: s.Artist,
|
||||
artist: l.getArtistForScrobble(&s.MediaFile),
|
||||
track: s.Title,
|
||||
album: s.Album,
|
||||
trackNumber: s.TrackNumber,
|
||||
@@ -344,6 +351,8 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
@@ -351,6 +360,8 @@ func init() {
|
||||
return nil
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
// Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
|
||||
@@ -196,6 +196,12 @@ var _ = Describe("lastfmAgent", func() {
|
||||
TrackNumber: 1,
|
||||
Duration: 180,
|
||||
MbzRecordingID: "mbz-123",
|
||||
Participants: map[model.Role]model.ParticipantList{
|
||||
model.RoleArtist: []model.Participant{
|
||||
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
|
||||
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -247,6 +253,23 @@ var _ = Describe("lastfmAgent", func() {
|
||||
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
|
||||
})
|
||||
|
||||
When("ScrobbleFirstArtistOnly is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
||||
})
|
||||
|
||||
It("uses only the first artist", func() {
|
||||
ts := time.Now()
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
||||
})
|
||||
})
|
||||
|
||||
It("skips songs with less than 31 seconds", func() {
|
||||
track.Duration = 29
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
2
core/agents/mcp/mcp-server/.gitignore
vendored
2
core/agents/mcp/mcp-server/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
mcp-server
|
||||
*.wasm
|
||||
@@ -1,17 +0,0 @@
|
||||
# MCP Server (Proof of Concept)
|
||||
|
||||
This directory contains the source code for the `mcp-server`, a simple server implementation used as a proof-of-concept (PoC) for the Navidrome Plugin/MCP agent system.
|
||||
|
||||
This server is designed to be compiled into a WebAssembly (WASM) module (`.wasm`) using the `wasip1` target.
|
||||
|
||||
## Compilation
|
||||
|
||||
To compile the server into a WASM module (`mcp-server.wasm`), navigate to this directory in your terminal and run the following command:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build -o mcp-server.wasm .
|
||||
```
|
||||
|
||||
**Note:** This command compiles the WASM module _without_ the `netgo` tag. Networking operations (like HTTP requests) are expected to be handled by host functions provided by the embedding application (Navidrome's `MCPAgent`) rather than directly within the WASM module itself.
|
||||
|
||||
Place the resulting `mcp-server.wasm` file where the Navidrome `MCPAgent` expects it (currently configured via the `McpServerPath` constant in `core/agents/mcp/mcp_agent.go`).
|
||||
@@ -1,172 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json" // Reusing ErrNotFound from wikidata.go (implicitly via main)
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dbpediaEndpoint = "https://dbpedia.org/sparql"
|
||||
|
||||
// Default timeout for DBpedia requests
|
||||
const defaultDbpediaTimeout = 20 * time.Second
|
||||
|
||||
// Can potentially reuse SparqlResult, SparqlBindings, SparqlValue from wikidata.go
|
||||
// if the structure is identical. Assuming it is for now.
|
||||
|
||||
// GetArtistBioFromDBpedia queries DBpedia for an artist's abstract using their name.
|
||||
func GetArtistBioFromDBpedia(fetcher Fetcher, ctx context.Context, name string) (string, error) {
|
||||
log.Printf("[MCP] Debug: GetArtistBioFromDBpedia called for name: %s", name)
|
||||
if name == "" {
|
||||
log.Printf("[MCP] Error: GetArtistBioFromDBpedia requires a name.")
|
||||
return "", fmt.Errorf("name is required to query DBpedia by name")
|
||||
}
|
||||
|
||||
// Escape name for SPARQL query literal
|
||||
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
|
||||
|
||||
// SPARQL query using DBpedia ontology (dbo)
|
||||
// Prefixes are recommended but can be omitted if endpoint resolves them.
|
||||
// Searching case-insensitively on the label.
|
||||
// Filtering for dbo:MusicalArtist or dbo:Band.
|
||||
// Selecting the English abstract.
|
||||
sparqlQuery := fmt.Sprintf(`
|
||||
PREFIX dbo: <http://dbpedia.org/ontology/>
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||
|
||||
SELECT DISTINCT ?abstract WHERE {
|
||||
?artist rdfs:label ?nameLabel .
|
||||
FILTER(LCASE(STR(?nameLabel)) = LCASE("%s") && LANG(?nameLabel) = "en")
|
||||
|
||||
# Ensure it's a musical artist or band
|
||||
{ ?artist rdf:type dbo:MusicalArtist } UNION { ?artist rdf:type dbo:Band }
|
||||
|
||||
?artist dbo:abstract ?abstract .
|
||||
FILTER(LANG(?abstract) = "en")
|
||||
} LIMIT 1`, escapedName)
|
||||
|
||||
// Prepare and execute HTTP request
|
||||
queryValues := url.Values{}
|
||||
queryValues.Set("query", sparqlQuery)
|
||||
queryValues.Set("format", "application/sparql-results+json") // DBpedia standard format
|
||||
|
||||
reqURL := fmt.Sprintf("%s?%s", dbpediaEndpoint, queryValues.Encode())
|
||||
log.Printf("[MCP] Debug: DBpedia Bio Request URL: %s", reqURL)
|
||||
|
||||
timeout := defaultDbpediaTimeout
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeout = time.Until(deadline)
|
||||
}
|
||||
log.Printf("[MCP] Debug: Fetching from DBpedia with timeout: %v", timeout)
|
||||
|
||||
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: Fetcher failed for DBpedia bio request (name: '%s'): %v", name, err)
|
||||
return "", fmt.Errorf("failed to execute DBpedia request: %w", err)
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
log.Printf("[MCP] Error: DBpedia bio query failed for name '%s' with status %d: %s", name, statusCode, string(bodyBytes))
|
||||
return "", fmt.Errorf("DBpedia query failed with status %d: %s", statusCode, string(bodyBytes))
|
||||
}
|
||||
log.Printf("[MCP] Debug: DBpedia bio query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
|
||||
|
||||
var result SparqlResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
// Try reading the raw body for debugging if JSON parsing fails
|
||||
// (Seek back to the beginning might be needed if already read for error)
|
||||
// For simplicity, just return the parsing error now.
|
||||
log.Printf("[MCP] Error: Failed to decode DBpedia bio response for name '%s': %v", name, err)
|
||||
return "", fmt.Errorf("failed to decode DBpedia response: %w", err)
|
||||
}
|
||||
|
||||
// Extract the abstract
|
||||
if len(result.Results.Bindings) > 0 {
|
||||
if abstractVal, ok := result.Results.Bindings[0]["abstract"]; ok {
|
||||
log.Printf("[MCP] Debug: Found DBpedia abstract for '%s'.", name)
|
||||
return abstractVal.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Use the shared ErrNotFound
|
||||
log.Printf("[MCP] Warn: No abstract found on DBpedia for name '%s'.", name)
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// GetArtistWikipediaURLFromDBpedia queries DBpedia for an artist's Wikipedia URL using their name.
|
||||
func GetArtistWikipediaURLFromDBpedia(fetcher Fetcher, ctx context.Context, name string) (string, error) {
|
||||
log.Printf("[MCP] Debug: GetArtistWikipediaURLFromDBpedia called for name: %s", name)
|
||||
if name == "" {
|
||||
log.Printf("[MCP] Error: GetArtistWikipediaURLFromDBpedia requires a name.")
|
||||
return "", fmt.Errorf("name is required to query DBpedia by name for URL")
|
||||
}
|
||||
|
||||
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
|
||||
|
||||
// SPARQL query using foaf:isPrimaryTopicOf
|
||||
sparqlQuery := fmt.Sprintf(`
|
||||
PREFIX dbo: <http://dbpedia.org/ontology/>
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
|
||||
|
||||
SELECT DISTINCT ?wikiPage WHERE {
|
||||
?artist rdfs:label ?nameLabel .
|
||||
FILTER(LCASE(STR(?nameLabel)) = LCASE("%s") && LANG(?nameLabel) = "en")
|
||||
|
||||
{ ?artist rdf:type dbo:MusicalArtist } UNION { ?artist rdf:type dbo:Band }
|
||||
|
||||
?artist foaf:isPrimaryTopicOf ?wikiPage .
|
||||
# Ensure it links to the English Wikipedia
|
||||
FILTER(STRSTARTS(STR(?wikiPage), "https://en.wikipedia.org/"))
|
||||
} LIMIT 1`, escapedName)
|
||||
|
||||
// Prepare and execute HTTP request (similar structure to bio query)
|
||||
queryValues := url.Values{}
|
||||
queryValues.Set("query", sparqlQuery)
|
||||
queryValues.Set("format", "application/sparql-results+json")
|
||||
|
||||
reqURL := fmt.Sprintf("%s?%s", dbpediaEndpoint, queryValues.Encode())
|
||||
log.Printf("[MCP] Debug: DBpedia URL Request URL: %s", reqURL)
|
||||
|
||||
timeout := defaultDbpediaTimeout
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeout = time.Until(deadline)
|
||||
}
|
||||
log.Printf("[MCP] Debug: Fetching DBpedia URL with timeout: %v", timeout)
|
||||
|
||||
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: Fetcher failed for DBpedia URL request (name: '%s'): %v", name, err)
|
||||
return "", fmt.Errorf("failed to execute DBpedia URL request: %w", err)
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
log.Printf("[MCP] Error: DBpedia URL query failed for name '%s' with status %d: %s", name, statusCode, string(bodyBytes))
|
||||
return "", fmt.Errorf("DBpedia URL query failed with status %d: %s", statusCode, string(bodyBytes))
|
||||
}
|
||||
log.Printf("[MCP] Debug: DBpedia URL query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
|
||||
|
||||
var result SparqlResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
log.Printf("[MCP] Error: Failed to decode DBpedia URL response for name '%s': %v", name, err)
|
||||
return "", fmt.Errorf("failed to decode DBpedia URL response: %w", err)
|
||||
}
|
||||
|
||||
// Extract the URL
|
||||
if len(result.Results.Bindings) > 0 {
|
||||
if pageVal, ok := result.Results.Bindings[0]["wikiPage"]; ok {
|
||||
log.Printf("[MCP] Debug: Found DBpedia Wikipedia URL for '%s': %s", name, pageVal.Value)
|
||||
return pageVal.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Warn: No Wikipedia URL found on DBpedia for name '%s'.", name)
|
||||
return "", ErrNotFound
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fetcher defines an interface for making HTTP requests, abstracting
|
||||
// over native net/http and WASM host functions.
|
||||
type Fetcher interface {
|
||||
// Fetch performs an HTTP request.
|
||||
// Returns the status code, response body, and any error encountered.
|
||||
// Note: Implementations should aim to return the body even on non-2xx status codes
|
||||
// if the body was successfully read, allowing callers to potentially inspect it.
|
||||
Fetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
//go:build !wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type nativeFetcher struct {
|
||||
// We could hold a shared client, but creating one per request
|
||||
// with the specific timeout is simpler for this adapter.
|
||||
}
|
||||
|
||||
// Ensure nativeFetcher implements Fetcher
|
||||
var _ Fetcher = (*nativeFetcher)(nil)
|
||||
|
||||
// NewFetcher creates the default native HTTP fetcher.
|
||||
func NewFetcher() Fetcher {
|
||||
log.Println("[MCP] Debug: Using Native HTTP fetcher")
|
||||
return &nativeFetcher{}
|
||||
}
|
||||
|
||||
func (nf *nativeFetcher) Fetch(ctx context.Context, method, urlStr string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
|
||||
log.Printf("[MCP] Debug: Native Fetch: Method=%s, URL=%s, Timeout=%v", method, urlStr, timeout)
|
||||
// Create a client with the specific timeout for this request
|
||||
client := &http.Client{Timeout: timeout}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if requestBody != nil {
|
||||
bodyReader = bytes.NewReader(requestBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, urlStr, bodyReader)
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: Native Fetch failed to create request: %v", err)
|
||||
return 0, nil, fmt.Errorf("failed to create native request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers consistent with previous direct client usage
|
||||
req.Header.Set("Accept", "application/sparql-results+json, application/json")
|
||||
// Note: Specific User-Agent was set per call site previously, might need adjustment
|
||||
// if different user agents are desired per service.
|
||||
req.Header.Set("User-Agent", "MCPGoServerExample/0.1 (Native Client)")
|
||||
|
||||
log.Printf("[MCP] Debug: Native Fetch executing request...")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// Let context cancellation errors pass through
|
||||
if ctx.Err() != nil {
|
||||
log.Printf("[MCP] Debug: Native Fetch context cancelled: %v", ctx.Err())
|
||||
return 0, nil, ctx.Err()
|
||||
}
|
||||
log.Printf("[MCP] Error: Native Fetch HTTP request failed: %v", err)
|
||||
return 0, nil, fmt.Errorf("native HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
statusCode = resp.StatusCode
|
||||
log.Printf("[MCP] Debug: Native Fetch received status code: %d", statusCode)
|
||||
responseBodyBytes, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
// Still return status code if body read fails
|
||||
log.Printf("[MCP] Error: Native Fetch failed to read response body: %v", readErr)
|
||||
return statusCode, nil, fmt.Errorf("failed to read native response body: %w", readErr)
|
||||
}
|
||||
responseBody = responseBodyBytes
|
||||
log.Printf("[MCP] Debug: Native Fetch read %d bytes from response body", len(responseBodyBytes))
|
||||
|
||||
// Mimic behavior of returning body even on error status
|
||||
if statusCode < 200 || statusCode >= 300 {
|
||||
log.Printf("[MCP] Warn: Native Fetch request failed with status %d. Body: %s", statusCode, string(responseBody))
|
||||
return statusCode, responseBody, fmt.Errorf("native request failed with status %d", statusCode)
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Debug: Native Fetch completed successfully.")
|
||||
return statusCode, responseBody, nil
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//go:build wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// --- WASM Host Function Import --- (Copied from user prompt)
|
||||
|
||||
//go:wasmimport env http_fetch
|
||||
//go:noescape
|
||||
func http_fetch(
|
||||
// Request details
|
||||
urlPtr, urlLen uint32,
|
||||
methodPtr, methodLen uint32,
|
||||
bodyPtr, bodyLen uint32,
|
||||
timeoutMillis uint32,
|
||||
// Result pointers
|
||||
resultStatusPtr uint32,
|
||||
resultBodyPtr uint32, resultBodyCapacity uint32, resultBodyLenPtr uint32,
|
||||
resultErrorPtr uint32, resultErrorCapacity uint32, resultErrorLenPtr uint32,
|
||||
) uint32 // 0 on success, 1 on host error
|
||||
|
||||
// --- Go Wrapper for Host Function --- (Copied from user prompt)
|
||||
|
||||
const (
|
||||
defaultResponseBodyCapacity = 1024 * 10 // 10 KB for response body
|
||||
defaultResponseErrorCapacity = 1024 // 1 KB for error messages
|
||||
)
|
||||
|
||||
// callHostHTTPFetch provides a Go-friendly interface to the http_fetch host function.
|
||||
func callHostHTTPFetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
|
||||
log.Printf("[MCP] Debug: WASM Fetch (Host Call): Method=%s, URL=%s, Timeout=%v", method, url, timeout)
|
||||
|
||||
// --- Prepare Input Pointers ---
|
||||
urlPtr, urlLen := stringToPtr(url)
|
||||
methodPtr, methodLen := stringToPtr(method)
|
||||
bodyPtr, bodyLen := bytesToPtr(requestBody)
|
||||
|
||||
timeoutMillis := uint32(timeout.Milliseconds())
|
||||
if timeoutMillis <= 0 {
|
||||
timeoutMillis = 30000 // Default 30 seconds if 0 or negative
|
||||
}
|
||||
if timeout == 0 {
|
||||
// Handle case where context might already be cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("[MCP] Debug: WASM Fetch context cancelled before host call: %v", ctx.Err())
|
||||
return 0, nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prepare Output Buffers and Pointers ---
|
||||
resultBodyBuffer := make([]byte, defaultResponseBodyCapacity)
|
||||
resultErrorBuffer := make([]byte, defaultResponseErrorCapacity)
|
||||
|
||||
resultStatus := uint32(0)
|
||||
resultBodyLen := uint32(0)
|
||||
resultErrorLen := uint32(0)
|
||||
|
||||
resultStatusPtr := &resultStatus
|
||||
resultBodyPtr, resultBodyCapacity := bytesToPtr(resultBodyBuffer)
|
||||
resultBodyLenPtr := &resultBodyLen
|
||||
resultErrorPtr, resultErrorCapacity := bytesToPtr(resultErrorBuffer)
|
||||
resultErrorLenPtr := &resultErrorLen
|
||||
|
||||
// --- Call the Host Function ---
|
||||
log.Printf("[MCP] Debug: WASM Fetch calling host function http_fetch...")
|
||||
hostReturnCode := http_fetch(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
bodyPtr, bodyLen,
|
||||
timeoutMillis,
|
||||
uint32(uintptr(unsafe.Pointer(resultStatusPtr))),
|
||||
resultBodyPtr, resultBodyCapacity, uint32(uintptr(unsafe.Pointer(resultBodyLenPtr))),
|
||||
resultErrorPtr, resultErrorCapacity, uint32(uintptr(unsafe.Pointer(resultErrorLenPtr))),
|
||||
)
|
||||
log.Printf("[MCP] Debug: WASM Fetch host function returned code: %d", hostReturnCode)
|
||||
|
||||
// --- Process Results ---
|
||||
if hostReturnCode != 0 {
|
||||
err = errors.New("host function http_fetch failed internally")
|
||||
log.Printf("[MCP] Error: WASM Fetch host function failed: %v", err)
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
statusCode = int(resultStatus)
|
||||
log.Printf("[MCP] Debug: WASM Fetch received status code from host: %d", statusCode)
|
||||
|
||||
if resultErrorLen > 0 {
|
||||
actualErrorLen := min(resultErrorLen, resultErrorCapacity)
|
||||
errMsg := string(resultErrorBuffer[:actualErrorLen])
|
||||
err = errors.New(errMsg)
|
||||
log.Printf("[MCP] Error: WASM Fetch received error from host: %s", errMsg)
|
||||
return statusCode, nil, err
|
||||
}
|
||||
|
||||
if resultBodyLen > 0 {
|
||||
actualBodyLen := min(resultBodyLen, resultBodyCapacity)
|
||||
responseBody = make([]byte, actualBodyLen)
|
||||
copy(responseBody, resultBodyBuffer[:actualBodyLen])
|
||||
log.Printf("[MCP] Debug: WASM Fetch received %d bytes from host body (reported size: %d)", actualBodyLen, resultBodyLen)
|
||||
|
||||
if resultBodyLen > resultBodyCapacity {
|
||||
err = fmt.Errorf("response body truncated: received %d bytes, but actual size was %d", actualBodyLen, resultBodyLen)
|
||||
log.Printf("[MCP] Warn: WASM Fetch %v", err)
|
||||
return statusCode, responseBody, err // Return truncated body with error
|
||||
}
|
||||
log.Printf("[MCP] Debug: WASM Fetch completed successfully.")
|
||||
return statusCode, responseBody, nil
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Debug: WASM Fetch completed successfully (no body, no error).")
|
||||
return statusCode, nil, nil
|
||||
}
|
||||
|
||||
// --- Pointer Helper Functions --- (Copied from user prompt)
|
||||
|
||||
func stringToPtr(s string) (ptr uint32, length uint32) {
|
||||
if len(s) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
// Use unsafe.StringData for potentially safer pointer access in modern Go
|
||||
// Needs Go 1.20+
|
||||
// return uint32(uintptr(unsafe.Pointer(unsafe.StringData(s)))), uint32(len(s))
|
||||
// Fallback to slice conversion for broader compatibility / if StringData isn't available
|
||||
buf := []byte(s)
|
||||
return bytesToPtr(buf)
|
||||
}
|
||||
|
||||
func bytesToPtr(b []byte) (ptr uint32, length uint32) {
|
||||
if len(b) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
// Use unsafe.SliceData for potentially safer pointer access in modern Go
|
||||
// Needs Go 1.20+
|
||||
// return uint32(uintptr(unsafe.Pointer(unsafe.SliceData(b)))), uint32(len(b))
|
||||
// Fallback for broader compatibility
|
||||
return uint32(uintptr(unsafe.Pointer(&b[0]))), uint32(len(b))
|
||||
}
|
||||
|
||||
func min(a, b uint32) uint32 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// --- WASM Fetcher Implementation ---
|
||||
type wasmFetcher struct{}
|
||||
|
||||
// Ensure wasmFetcher implements Fetcher
|
||||
var _ Fetcher = (*wasmFetcher)(nil)
|
||||
|
||||
// NewFetcher creates the WASM host function fetcher.
|
||||
func NewFetcher() Fetcher {
|
||||
log.Println("[MCP] Debug: Using WASM host fetcher")
|
||||
return &wasmFetcher{}
|
||||
}
|
||||
|
||||
func (wf *wasmFetcher) Fetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
|
||||
// Directly call the wrapper which now contains logging
|
||||
return callHostHTTPFetch(ctx, method, url, requestBody, timeout)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
module mcp-server
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require github.com/metoro-io/mcp-golang v0.11.0
|
||||
|
||||
require (
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/invopop/jsonschema v0.12.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
|
||||
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/metoro-io/mcp-golang v0.11.0 h1:1k+VSE9QaeMTLn0gJ3FgE/DcjsCBsLFnz5eSFbgXUiI=
|
||||
github.com/metoro-io/mcp-golang v0.11.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,289 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
mcp_golang "github.com/metoro-io/mcp-golang"
|
||||
"github.com/metoro-io/mcp-golang/transport/stdio"
|
||||
)
|
||||
|
||||
type Content struct {
|
||||
Title string `json:"title" jsonschema:"required,description=The title to submit"`
|
||||
Description *string `json:"description" jsonschema:"description=The description to submit"`
|
||||
}
|
||||
type MyFunctionsArguments struct {
|
||||
Submitter string `json:"submitter" jsonschema:"required,description=The name of the thing calling this tool (openai, google, claude, etc)"`
|
||||
Content Content `json:"content" jsonschema:"required,description=The content of the message"`
|
||||
}
|
||||
|
||||
type ArtistBiography struct {
|
||||
ID string `json:"id" jsonschema:"required,description=The id of the artist"`
|
||||
Name string `json:"name" jsonschema:"required,description=The name of the artist"`
|
||||
MBID string `json:"mbid" jsonschema:"description=The mbid of the artist"`
|
||||
}
|
||||
|
||||
type ArtistURLArgs struct {
|
||||
ID string `json:"id" jsonschema:"required,description=The id of the artist"`
|
||||
Name string `json:"name" jsonschema:"required,description=The name of the artist"`
|
||||
MBID string `json:"mbid" jsonschema:"description=The mbid of the artist"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.Println("[MCP] Starting mcp-server...")
|
||||
done := make(chan struct{})
|
||||
|
||||
// Create the appropriate fetcher (native or WASM based on build tags)
|
||||
log.Printf("[MCP] Debug: Creating fetcher...")
|
||||
fetcher := NewFetcher()
|
||||
log.Printf("[MCP] Debug: Fetcher created successfully.")
|
||||
|
||||
// --- Command Line Flag Handling ---
|
||||
nameFlag := flag.String("name", "", "Artist name to query directly")
|
||||
mbidFlag := flag.String("mbid", "", "Artist MBID to query directly")
|
||||
flag.Parse()
|
||||
|
||||
if *nameFlag != "" || *mbidFlag != "" {
|
||||
log.Printf("[MCP] Debug: Running tools directly via CLI flags (Name: '%s', MBID: '%s')", *nameFlag, *mbidFlag)
|
||||
fmt.Println("--- Running Tools Directly ---")
|
||||
|
||||
// Call getArtistBiography
|
||||
fmt.Printf("Calling get_artist_biography (Name: '%s', MBID: '%s')...\n", *nameFlag, *mbidFlag)
|
||||
if *mbidFlag == "" && *nameFlag == "" {
|
||||
fmt.Println(" Error: --mbid or --name is required for get_artist_biography")
|
||||
} else {
|
||||
// Use context.Background for CLI calls
|
||||
log.Printf("[MCP] Debug: CLI calling getArtistBiography...")
|
||||
bio, bioErr := getArtistBiography(fetcher, context.Background(), "cli", *nameFlag, *mbidFlag)
|
||||
if bioErr != nil {
|
||||
fmt.Printf(" Error: %v\n", bioErr)
|
||||
log.Printf("[MCP] Error: CLI getArtistBiography failed: %v", bioErr)
|
||||
} else {
|
||||
fmt.Printf(" Result: %s\n", bio)
|
||||
log.Printf("[MCP] Debug: CLI getArtistBiography succeeded.")
|
||||
}
|
||||
}
|
||||
|
||||
// Call getArtistURL
|
||||
fmt.Printf("Calling get_artist_url (Name: '%s', MBID: '%s')...\n", *nameFlag, *mbidFlag)
|
||||
if *mbidFlag == "" && *nameFlag == "" {
|
||||
fmt.Println(" Error: --mbid or --name is required for get_artist_url")
|
||||
} else {
|
||||
log.Printf("[MCP] Debug: CLI calling getArtistURL...")
|
||||
urlResult, urlErr := getArtistURL(fetcher, context.Background(), "cli", *nameFlag, *mbidFlag)
|
||||
if urlErr != nil {
|
||||
fmt.Printf(" Error: %v\n", urlErr)
|
||||
log.Printf("[MCP] Error: CLI getArtistURL failed: %v", urlErr)
|
||||
} else {
|
||||
fmt.Printf(" Result: %s\n", urlResult)
|
||||
log.Printf("[MCP] Debug: CLI getArtistURL succeeded.")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("-----------------------------")
|
||||
log.Printf("[MCP] Debug: CLI execution finished.")
|
||||
return // Exit after direct execution
|
||||
}
|
||||
// --- End Command Line Flag Handling ---
|
||||
|
||||
log.Printf("[MCP] Debug: Initializing MCP server...")
|
||||
server := mcp_golang.NewServer(stdio.NewStdioServerTransport())
|
||||
|
||||
log.Printf("[MCP] Debug: Registering tool 'hello'...")
|
||||
err := server.RegisterTool("hello", "Say hello to a person", func(arguments MyFunctionsArguments) (*mcp_golang.ToolResponse, error) {
|
||||
log.Printf("[MCP] Debug: Tool 'hello' called with args: %+v", arguments)
|
||||
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(fmt.Sprintf("Hello, %server!", arguments.Submitter))), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("[MCP] Fatal: Failed to register tool 'hello': %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Debug: Registering tool 'get_artist_biography'...")
|
||||
err = server.RegisterTool("get_artist_biography", "Get the biography of an artist", func(arguments ArtistBiography) (*mcp_golang.ToolResponse, error) {
|
||||
log.Printf("[MCP] Debug: Tool 'get_artist_biography' called with args: %+v", arguments)
|
||||
// Using background context in handlers as request context isn't passed through MCP library currently
|
||||
bio, err := getArtistBiography(fetcher, context.Background(), arguments.ID, arguments.Name, arguments.MBID)
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: getArtistBiography handler failed: %v", err)
|
||||
return nil, fmt.Errorf("handler returned an error: %w", err) // Return structured error
|
||||
}
|
||||
log.Printf("[MCP] Debug: Tool 'get_artist_biography' succeeded.")
|
||||
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(bio)), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("[MCP] Fatal: Failed to register tool 'get_artist_biography': %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Debug: Registering tool 'get_artist_url'...")
|
||||
err = server.RegisterTool("get_artist_url", "Get the artist's specific Wikipedia URL via MBID, or a search URL using name as fallback", func(arguments ArtistURLArgs) (*mcp_golang.ToolResponse, error) {
|
||||
log.Printf("[MCP] Debug: Tool 'get_artist_url' called with args: %+v", arguments)
|
||||
urlResult, err := getArtistURL(fetcher, context.Background(), arguments.ID, arguments.Name, arguments.MBID)
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: getArtistURL handler failed: %v", err)
|
||||
return nil, fmt.Errorf("handler returned an error: %w", err)
|
||||
}
|
||||
log.Printf("[MCP] Debug: Tool 'get_artist_url' succeeded.")
|
||||
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(urlResult)), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("[MCP] Fatal: Failed to register tool 'get_artist_url': %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Debug: Registering prompt 'prompt_test'...")
|
||||
err = server.RegisterPrompt("prompt_test", "This is a test prompt", func(arguments Content) (*mcp_golang.PromptResponse, error) {
|
||||
log.Printf("[MCP] Debug: Prompt 'prompt_test' called with args: %+v", arguments)
|
||||
return mcp_golang.NewPromptResponse("description", mcp_golang.NewPromptMessage(mcp_golang.NewTextContent(fmt.Sprintf("Hello, %server!", arguments.Title)), mcp_golang.RoleUser)), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("[MCP] Fatal: Failed to register prompt 'prompt_test': %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Debug: Registering resource 'test://resource'...")
|
||||
err = server.RegisterResource("test://resource", "resource_test", "This is a test resource", "application/json", func() (*mcp_golang.ResourceResponse, error) {
|
||||
log.Printf("[MCP] Debug: Resource 'test://resource' called")
|
||||
return mcp_golang.NewResourceResponse(mcp_golang.NewTextEmbeddedResource("test://resource", "This is a test resource", "application/json")), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("[MCP] Fatal: Failed to register resource 'test://resource': %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Debug: Registering resource 'file://app_logs'...")
|
||||
err = server.RegisterResource("file://app_logs", "app_logs", "The app logs", "text/plain", func() (*mcp_golang.ResourceResponse, error) {
|
||||
log.Printf("[MCP] Debug: Resource 'file://app_logs' called")
|
||||
return mcp_golang.NewResourceResponse(mcp_golang.NewTextEmbeddedResource("file://app_logs", "This is a test resource", "text/plain")), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("[MCP] Fatal: Failed to register resource 'file://app_logs': %v", err)
|
||||
}
|
||||
|
||||
log.Println("[MCP] MCP server initialized and starting to serve...")
|
||||
err = server.Serve()
|
||||
if err != nil {
|
||||
log.Fatalf("[MCP] Fatal: Server exited with error: %v", err)
|
||||
}
|
||||
|
||||
log.Println("[MCP] Server exited cleanly.")
|
||||
<-done // Keep running until interrupted (though server.Serve() is blocking)
|
||||
}
|
||||
|
||||
func getArtistBiography(fetcher Fetcher, ctx context.Context, id, name, mbid string) (string, error) {
|
||||
log.Printf("[MCP] Debug: getArtistBiography called (id: %s, name: %s, mbid: %s)", id, name, mbid)
|
||||
if mbid == "" {
|
||||
fmt.Fprintf(os.Stderr, "MBID not provided, attempting DBpedia lookup by name: %s\n", name)
|
||||
log.Printf("[MCP] Debug: MBID not provided, attempting DBpedia lookup by name: %s", name)
|
||||
} else {
|
||||
// 1. Attempt Wikidata MBID lookup first
|
||||
log.Printf("[MCP] Debug: Attempting Wikidata URL lookup for MBID: %s", mbid)
|
||||
wikiURL, err := GetArtistWikipediaURL(fetcher, ctx, mbid)
|
||||
if err == nil {
|
||||
// 1a. Found Wikidata URL, now fetch from Wikipedia API
|
||||
log.Printf("[MCP] Debug: Found Wikidata URL '%s', fetching bio from Wikipedia API...", wikiURL)
|
||||
bio, errBio := GetBioFromWikipediaAPI(fetcher, ctx, wikiURL)
|
||||
if errBio == nil {
|
||||
log.Printf("[MCP] Debug: Successfully fetched bio from Wikipedia API for '%s'.", name)
|
||||
return bio, nil // Success via Wikidata/Wikipedia!
|
||||
} else {
|
||||
// Failed to get bio even though URL was found
|
||||
log.Printf("[MCP] Error: Found Wikipedia URL (%s) via MBID %s, but failed to fetch bio: %v", wikiURL, mbid, errBio)
|
||||
fmt.Fprintf(os.Stderr, "Found Wikipedia URL (%s) via MBID %s, but failed to fetch bio: %v\n", wikiURL, mbid, errBio)
|
||||
// Fall through to try DBpedia by name as a last resort?
|
||||
// Let's fall through for now.
|
||||
}
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
// Wikidata lookup failed for a reason other than not found (e.g., network)
|
||||
log.Printf("[MCP] Error: Wikidata URL lookup failed for MBID %s (non-NotFound error): %v", mbid, err)
|
||||
fmt.Fprintf(os.Stderr, "Wikidata URL lookup failed for MBID %s (non-NotFound error): %v\n", mbid, err)
|
||||
// Don't proceed to DBpedia name lookup if Wikidata had a technical failure
|
||||
return "", fmt.Errorf("Wikidata lookup failed: %w", err)
|
||||
} else {
|
||||
// Wikidata lookup returned ErrNotFound for MBID
|
||||
log.Printf("[MCP] Debug: MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s", mbid, name)
|
||||
fmt.Fprintf(os.Stderr, "MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s\n", mbid, name)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Attempt DBpedia lookup by name (if MBID was missing or failed with ErrNotFound)
|
||||
if name == "" {
|
||||
log.Printf("[MCP] Error: Cannot find artist bio: MBID lookup failed/missing, and no name provided.")
|
||||
return "", fmt.Errorf("cannot find artist: MBID lookup failed or MBID not provided, and no name provided for DBpedia fallback")
|
||||
}
|
||||
log.Printf("[MCP] Debug: Attempting DBpedia bio lookup by name: %s", name)
|
||||
dbpediaBio, errDb := GetArtistBioFromDBpedia(fetcher, ctx, name)
|
||||
if errDb == nil {
|
||||
log.Printf("[MCP] Debug: Successfully fetched bio from DBpedia for '%s'.", name)
|
||||
return dbpediaBio, nil // Success via DBpedia!
|
||||
}
|
||||
|
||||
// 3. If both Wikidata (MBID) and DBpedia (Name) failed
|
||||
if errors.Is(errDb, ErrNotFound) {
|
||||
log.Printf("[MCP] Error: Artist '%s' (MBID: %s) not found via Wikidata or DBpedia name lookup.", name, mbid)
|
||||
return "", fmt.Errorf("artist '%s' (MBID: %s) not found via Wikidata MBID or DBpedia Name lookup", name, mbid)
|
||||
}
|
||||
|
||||
// Return DBpedia's error if it wasn't ErrNotFound
|
||||
log.Printf("[MCP] Error: DBpedia lookup failed for name '%s': %v", name, errDb)
|
||||
return "", fmt.Errorf("DBpedia lookup failed for name '%s': %w", name, errDb)
|
||||
}
|
||||
|
||||
// getArtistURL attempts to find the specific Wikipedia URL using MBID (via Wikidata),
|
||||
// then by Name (via DBpedia), falling back to a search URL using name.
|
||||
func getArtistURL(fetcher Fetcher, ctx context.Context, id, name, mbid string) (string, error) {
|
||||
log.Printf("[MCP] Debug: getArtistURL called (id: %s, name: %s, mbid: %s)", id, name, mbid)
|
||||
if mbid == "" {
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: MBID not provided, attempting DBpedia lookup by name: %s\n", name)
|
||||
log.Printf("[MCP] Debug: getArtistURL: MBID not provided, attempting DBpedia lookup by name: %s", name)
|
||||
} else {
|
||||
// Try to get the specific URL from Wikidata using MBID
|
||||
log.Printf("[MCP] Debug: getArtistURL: Attempting Wikidata URL lookup for MBID: %s", mbid)
|
||||
wikiURL, err := GetArtistWikipediaURL(fetcher, ctx, mbid)
|
||||
if err == nil && wikiURL != "" {
|
||||
log.Printf("[MCP] Debug: getArtistURL: Found specific URL '%s' via Wikidata MBID.", wikiURL)
|
||||
return wikiURL, nil // Found specific URL via MBID
|
||||
}
|
||||
// Log error if Wikidata lookup failed for reasons other than not found
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
log.Printf("[MCP] Error: getArtistURL: Wikidata URL lookup failed for MBID %s (non-NotFound error): %v", mbid, err)
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: Wikidata URL lookup failed for MBID %s (non-NotFound error): %v\n", mbid, err)
|
||||
// Fall through to try DBpedia if name is available
|
||||
} else if errors.Is(err, ErrNotFound) {
|
||||
log.Printf("[MCP] Debug: getArtistURL: MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s", mbid, name)
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s\n", mbid, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 1: Try DBpedia lookup by name
|
||||
if name != "" {
|
||||
log.Printf("[MCP] Debug: getArtistURL: Attempting DBpedia URL lookup by name: %s", name)
|
||||
dbpediaWikiURL, errDb := GetArtistWikipediaURLFromDBpedia(fetcher, ctx, name)
|
||||
if errDb == nil && dbpediaWikiURL != "" {
|
||||
log.Printf("[MCP] Debug: getArtistURL: Found specific URL '%s' via DBpedia Name lookup.", dbpediaWikiURL)
|
||||
return dbpediaWikiURL, nil // Found specific URL via DBpedia Name lookup
|
||||
}
|
||||
// Log error if DBpedia lookup failed for reasons other than not found
|
||||
if errDb != nil && !errors.Is(errDb, ErrNotFound) {
|
||||
log.Printf("[MCP] Error: getArtistURL: DBpedia URL lookup failed for name '%s' (non-NotFound error): %v", name, errDb)
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: DBpedia URL lookup failed for name '%s' (non-NotFound error): %v\n", name, errDb)
|
||||
// Fall through to search URL fallback
|
||||
} else if errors.Is(errDb, ErrNotFound) {
|
||||
log.Printf("[MCP] Debug: getArtistURL: Name '%s' not found on DBpedia, attempting search fallback", name)
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: Name '%s' not found on DBpedia, attempting search fallback\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 2: Generate a search URL if name is provided
|
||||
if name != "" {
|
||||
searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(name))
|
||||
log.Printf("[MCP] Debug: getArtistURL: Falling back to search URL: %s", searchURL)
|
||||
fmt.Fprintf(os.Stderr, "getArtistURL: Falling back to search URL: %s\n", searchURL)
|
||||
return searchURL, nil
|
||||
}
|
||||
|
||||
// Final error: MBID lookup failed (or no MBID given) AND no name provided for fallback
|
||||
log.Printf("[MCP] Error: getArtistURL: Cannot generate Wikipedia URL: Lookups failed and no name provided.")
|
||||
return "", fmt.Errorf("cannot generate Wikipedia URL: Wikidata/DBpedia lookups failed and no artist name provided for search fallback")
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const wikidataEndpoint = "https://query.wikidata.org/sparql"
|
||||
|
||||
// ErrNotFound indicates a specific item (like an artist or URL) was not found on Wikidata.
|
||||
var ErrNotFound = errors.New("item not found on Wikidata")
|
||||
|
||||
// Wikidata SPARQL query result structures
|
||||
type SparqlResult struct {
|
||||
Results SparqlBindings `json:"results"`
|
||||
}
|
||||
|
||||
type SparqlBindings struct {
|
||||
Bindings []map[string]SparqlValue `json:"bindings"`
|
||||
}
|
||||
|
||||
type SparqlValue struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Lang string `json:"xml:lang,omitempty"` // Handle language tags like "en"
|
||||
}
|
||||
|
||||
// GetArtistBioFromWikidata queries Wikidata for an artist's description using their MBID.
|
||||
// NOTE: This function is currently UNUSED as the main logic prefers Wikipedia/DBpedia.
|
||||
func GetArtistBioFromWikidata(client *http.Client, mbid string) (string, error) {
|
||||
log.Printf("[MCP] Debug: GetArtistBioFromWikidata called for MBID: %s", mbid)
|
||||
if mbid == "" {
|
||||
log.Printf("[MCP] Error: GetArtistBioFromWikidata requires an MBID.")
|
||||
return "", fmt.Errorf("MBID is required to query Wikidata")
|
||||
}
|
||||
|
||||
// SPARQL query to find the English description for an entity with a specific MusicBrainz ID
|
||||
sparqlQuery := fmt.Sprintf(`
|
||||
SELECT ?artistDescription WHERE {
|
||||
?artist wdt:P434 "%s" . # P434 is the property for MusicBrainz artist ID
|
||||
OPTIONAL {
|
||||
?artist schema:description ?artistDescription .
|
||||
FILTER(LANG(?artistDescription) = "en")
|
||||
}
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
||||
}
|
||||
LIMIT 1`, mbid)
|
||||
|
||||
// Prepare the HTTP request
|
||||
queryValues := url.Values{}
|
||||
queryValues.Set("query", sparqlQuery)
|
||||
queryValues.Set("format", "json")
|
||||
|
||||
reqURL := fmt.Sprintf("%s?%s", wikidataEndpoint, queryValues.Encode())
|
||||
log.Printf("[MCP] Debug: Wikidata Bio Request URL: %s", reqURL)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: Failed to create Wikidata bio request: %v", err)
|
||||
return "", fmt.Errorf("failed to create Wikidata request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/sparql-results+json")
|
||||
req.Header.Set("User-Agent", "MCPGoServerExample/0.1 (https://example.com/contact)") // Good practice to identify your client
|
||||
|
||||
// Execute the request
|
||||
log.Printf("[MCP] Debug: Executing Wikidata bio request...")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: Failed to execute Wikidata bio request: %v", err)
|
||||
return "", fmt.Errorf("failed to execute Wikidata request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Attempt to read body for more error info, but don't fail if it doesn't work
|
||||
bodyBytes, readErr := io.ReadAll(resp.Body)
|
||||
errorMsg := "Could not read error body"
|
||||
if readErr == nil {
|
||||
errorMsg = string(bodyBytes)
|
||||
}
|
||||
log.Printf("[MCP] Error: Wikidata bio query failed with status %d: %s", resp.StatusCode, errorMsg)
|
||||
return "", fmt.Errorf("Wikidata query failed with status %d: %s", resp.StatusCode, errorMsg)
|
||||
}
|
||||
log.Printf("[MCP] Debug: Wikidata bio query successful (status %d).", resp.StatusCode)
|
||||
|
||||
// Parse the response
|
||||
var result SparqlResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
log.Printf("[MCP] Error: Failed to decode Wikidata bio response: %v", err)
|
||||
return "", fmt.Errorf("failed to decode Wikidata response: %w", err)
|
||||
}
|
||||
|
||||
// Extract the description
|
||||
if len(result.Results.Bindings) > 0 {
|
||||
if descriptionVal, ok := result.Results.Bindings[0]["artistDescription"]; ok {
|
||||
log.Printf("[MCP] Debug: Found description for MBID %s", mbid)
|
||||
return descriptionVal.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Warn: No English description found on Wikidata for MBID %s", mbid)
|
||||
return "", fmt.Errorf("no English description found on Wikidata for MBID %s", mbid)
|
||||
}
|
||||
|
||||
// GetArtistWikipediaURL queries Wikidata for an artist's English Wikipedia page URL using MBID.
|
||||
// It tries searching by MBID first, then falls back to searching by name.
|
||||
func GetArtistWikipediaURL(fetcher Fetcher, ctx context.Context, mbid string) (string, error) {
|
||||
log.Printf("[MCP] Debug: GetArtistWikipediaURL called for MBID: %s", mbid)
|
||||
// 1. Try finding by MBID
|
||||
if mbid == "" {
|
||||
log.Printf("[MCP] Error: GetArtistWikipediaURL requires an MBID.")
|
||||
return "", fmt.Errorf("MBID is required to find Wikipedia URL on Wikidata")
|
||||
} else {
|
||||
// SPARQL query to find the enwiki URL for an entity with a specific MusicBrainz ID
|
||||
sparqlQuery := fmt.Sprintf(`
|
||||
SELECT ?article WHERE {
|
||||
?artist wdt:P434 "%s" . # P434 is MusicBrainz artist ID
|
||||
?article schema:about ?artist ;
|
||||
schema:isPartOf <https://en.wikipedia.org/> .
|
||||
}
|
||||
LIMIT 1`, mbid)
|
||||
|
||||
log.Printf("[MCP] Debug: Executing Wikidata URL query for MBID: %s", mbid)
|
||||
foundURL, err := executeWikidataURLQuery(fetcher, ctx, sparqlQuery)
|
||||
if err == nil && foundURL != "" {
|
||||
log.Printf("[MCP] Debug: Found Wikipedia URL '%s' via MBID %s", foundURL, mbid)
|
||||
return foundURL, nil // Found via MBID
|
||||
}
|
||||
// Use the specific ErrNotFound
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
log.Printf("[MCP] Debug: MBID %s not found on Wikidata for URL lookup.", mbid)
|
||||
return "", ErrNotFound // Explicitly return ErrNotFound
|
||||
}
|
||||
// Log other errors
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: Wikidata URL lookup via MBID %s failed: %v", mbid, err)
|
||||
fmt.Fprintf(os.Stderr, "Wikidata URL lookup via MBID %s failed: %v\n", mbid, err)
|
||||
return "", fmt.Errorf("Wikidata URL lookup via MBID failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Should ideally not be reached if MBID is required and lookup failed or was not found
|
||||
log.Printf("[MCP] Warn: Reached end of GetArtistWikipediaURL unexpectedly for MBID %s", mbid)
|
||||
return "", ErrNotFound // Return ErrNotFound if somehow reached
|
||||
}
|
||||
|
||||
// executeWikidataURLQuery is a helper to run SPARQL and extract the first bound URL for '?article'.
|
||||
func executeWikidataURLQuery(fetcher Fetcher, ctx context.Context, sparqlQuery string) (string, error) {
|
||||
log.Printf("[MCP] Debug: executeWikidataURLQuery called.")
|
||||
queryValues := url.Values{}
|
||||
queryValues.Set("query", sparqlQuery)
|
||||
queryValues.Set("format", "json")
|
||||
|
||||
reqURL := fmt.Sprintf("%s?%s", wikidataEndpoint, queryValues.Encode())
|
||||
log.Printf("[MCP] Debug: Wikidata Sparql Request URL: %s", reqURL)
|
||||
|
||||
// Directly use the fetcher
|
||||
// Note: Headers (Accept, User-Agent) are now handled by the Fetcher implementation
|
||||
// The WASM fetcher currently doesn't support setting them via the host func interface.
|
||||
// Timeout is handled via context for native, and passed to host func for WASM.
|
||||
// Let's use a default timeout here if not provided via context (e.g., 15s)
|
||||
// TODO: Consider making timeout configurable or passed down
|
||||
timeout := 15 * time.Second
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeout = time.Until(deadline)
|
||||
}
|
||||
log.Printf("[MCP] Debug: Fetching from Wikidata with timeout: %v", timeout)
|
||||
|
||||
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: Fetcher failed for Wikidata SPARQL request: %v", err)
|
||||
return "", fmt.Errorf("failed to execute Wikidata request: %w", err)
|
||||
}
|
||||
|
||||
// Check status code. Fetcher interface implies body might be returned even on error.
|
||||
if statusCode != http.StatusOK {
|
||||
log.Printf("[MCP] Error: Wikidata SPARQL query failed with status %d: %s", statusCode, string(bodyBytes))
|
||||
return "", fmt.Errorf("Wikidata query failed with status %d: %s", statusCode, string(bodyBytes))
|
||||
}
|
||||
log.Printf("[MCP] Debug: Wikidata SPARQL query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
|
||||
|
||||
var result SparqlResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil { // Use Unmarshal for byte slice
|
||||
log.Printf("[MCP] Error: Failed to decode Wikidata SPARQL response: %v", err)
|
||||
return "", fmt.Errorf("failed to decode Wikidata response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Results.Bindings) > 0 {
|
||||
if articleVal, ok := result.Results.Bindings[0]["article"]; ok {
|
||||
log.Printf("[MCP] Debug: Found Wikidata article URL: %s", articleVal.Value)
|
||||
return articleVal.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Debug: No Wikidata article URL found in SPARQL response.")
|
||||
return "", ErrNotFound
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mediaWikiAPIEndpoint = "https://en.wikipedia.org/w/api.php"
|
||||
|
||||
// Structures for parsing MediaWiki API response (query extracts)
|
||||
type MediaWikiQueryResult struct {
|
||||
Query MediaWikiQuery `json:"query"`
|
||||
}
|
||||
|
||||
type MediaWikiQuery struct {
|
||||
Pages map[string]MediaWikiPage `json:"pages"`
|
||||
}
|
||||
|
||||
type MediaWikiPage struct {
|
||||
PageID int `json:"pageid"`
|
||||
Ns int `json:"ns"`
|
||||
Title string `json:"title"`
|
||||
Extract string `json:"extract"`
|
||||
}
|
||||
|
||||
// Default timeout for Wikipedia API requests
|
||||
const defaultWikipediaTimeout = 15 * time.Second
|
||||
|
||||
// GetBioFromWikipediaAPI fetches the introductory text of a Wikipedia page.
|
||||
func GetBioFromWikipediaAPI(fetcher Fetcher, ctx context.Context, wikipediaURL string) (string, error) {
|
||||
log.Printf("[MCP] Debug: GetBioFromWikipediaAPI called for URL: %s", wikipediaURL)
|
||||
pageTitle, err := extractPageTitleFromURL(wikipediaURL)
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: Could not extract title from Wikipedia URL '%s': %v", wikipediaURL, err)
|
||||
return "", fmt.Errorf("could not extract title from Wikipedia URL %s: %w", wikipediaURL, err)
|
||||
}
|
||||
log.Printf("[MCP] Debug: Extracted Wikipedia page title: %s", pageTitle)
|
||||
|
||||
// Prepare API request parameters
|
||||
apiParams := url.Values{}
|
||||
apiParams.Set("action", "query")
|
||||
apiParams.Set("format", "json")
|
||||
apiParams.Set("prop", "extracts") // Request page extracts
|
||||
apiParams.Set("exintro", "true") // Get only the intro section
|
||||
apiParams.Set("explaintext", "true") // Get plain text instead of HTML
|
||||
apiParams.Set("titles", pageTitle) // Specify the page title
|
||||
apiParams.Set("redirects", "1") // Follow redirects
|
||||
|
||||
reqURL := fmt.Sprintf("%s?%s", mediaWikiAPIEndpoint, apiParams.Encode())
|
||||
log.Printf("[MCP] Debug: MediaWiki API Request URL: %s", reqURL)
|
||||
|
||||
timeout := defaultWikipediaTimeout
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeout = time.Until(deadline)
|
||||
}
|
||||
log.Printf("[MCP] Debug: Fetching from MediaWiki with timeout: %v", timeout)
|
||||
|
||||
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
|
||||
if err != nil {
|
||||
log.Printf("[MCP] Error: Fetcher failed for MediaWiki request (title: '%s'): %v", pageTitle, err)
|
||||
return "", fmt.Errorf("failed to execute MediaWiki request for title '%s': %w", pageTitle, err)
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
log.Printf("[MCP] Error: MediaWiki query for '%s' failed with status %d: %s", pageTitle, statusCode, string(bodyBytes))
|
||||
return "", fmt.Errorf("MediaWiki query for '%s' failed with status %d: %s", pageTitle, statusCode, string(bodyBytes))
|
||||
}
|
||||
log.Printf("[MCP] Debug: MediaWiki query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
|
||||
|
||||
// Parse the response
|
||||
var result MediaWikiQueryResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
log.Printf("[MCP] Error: Failed to decode MediaWiki response for '%s': %v", pageTitle, err)
|
||||
return "", fmt.Errorf("failed to decode MediaWiki response for '%s': %w", pageTitle, err)
|
||||
}
|
||||
|
||||
// Extract the text - MediaWiki API returns pages keyed by page ID
|
||||
for pageID, page := range result.Query.Pages {
|
||||
log.Printf("[MCP] Debug: Processing MediaWiki page ID: %s, Title: %s", pageID, page.Title)
|
||||
if page.Extract != "" {
|
||||
// Often includes a newline at the end, trim it
|
||||
log.Printf("[MCP] Debug: Found extract for '%s'. Length: %d", pageTitle, len(page.Extract))
|
||||
return strings.TrimSpace(page.Extract), nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[MCP] Warn: No extract found in MediaWiki response for title '%s'", pageTitle)
|
||||
return "", fmt.Errorf("no extract found in MediaWiki response for title '%s' (page might not exist or be empty)", pageTitle)
|
||||
}
|
||||
|
||||
// extractPageTitleFromURL attempts to get the page title from a standard Wikipedia URL.
|
||||
// Example: https://en.wikipedia.org/wiki/The_Beatles -> The_Beatles
|
||||
func extractPageTitleFromURL(wikiURL string) (string, error) {
|
||||
parsedURL, err := url.Parse(wikiURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsedURL.Host != "en.wikipedia.org" {
|
||||
return "", fmt.Errorf("URL host is not en.wikipedia.org: %s", parsedURL.Host)
|
||||
}
|
||||
pathParts := strings.Split(strings.TrimPrefix(parsedURL.Path, "/"), "/")
|
||||
if len(pathParts) < 2 || pathParts[0] != "wiki" {
|
||||
return "", fmt.Errorf("URL path does not match /wiki/<title> format: %s", parsedURL.Path)
|
||||
}
|
||||
title := pathParts[1]
|
||||
if title == "" {
|
||||
return "", fmt.Errorf("extracted title is empty")
|
||||
}
|
||||
// URL Decode the title (e.g., %27 -> ')
|
||||
decodedTitle, err := url.PathUnescape(title)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode title '%s': %w", title, err)
|
||||
}
|
||||
return decodedTitle, nil
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mcp "github.com/metoro-io/mcp-golang"
|
||||
"github.com/tetratelabs/wazero"
|
||||
|
||||
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// Constants used by the MCP agent
|
||||
const (
|
||||
McpAgentName = "mcp"
|
||||
initializationTimeout = 5 * time.Second
|
||||
// McpServerPath defines the location of the MCP server executable or WASM module.
|
||||
// McpServerPath = "./core/agents/mcp/mcp-server/mcp-server"
|
||||
McpServerPath = "./core/agents/mcp/mcp-server/mcp-server.wasm"
|
||||
McpToolNameGetBio = "get_artist_biography"
|
||||
McpToolNameGetURL = "get_artist_url"
|
||||
)
|
||||
|
||||
// mcpClient interface matching the methods used from mcp.Client.
|
||||
type mcpClient interface {
|
||||
Initialize(ctx context.Context) (*mcp.InitializeResponse, error)
|
||||
CallTool(ctx context.Context, toolName string, args any) (*mcp.ToolResponse, error)
|
||||
}
|
||||
|
||||
// mcpImplementation defines the common interface for both native and WASM MCP agents.
|
||||
// This allows the main MCPAgent to delegate calls without knowing the underlying type.
|
||||
type mcpImplementation interface {
|
||||
Close() error // For cleaning up resources associated with this specific implementation.
|
||||
|
||||
// callMCPTool is the core method implemented differently by native/wasm
|
||||
callMCPTool(ctx context.Context, toolName string, args any) (string, error)
|
||||
}
|
||||
|
||||
// MCPAgent is the public-facing agent registered with Navidrome.
|
||||
// It acts as a wrapper around the actual implementation (native or WASM).
|
||||
type MCPAgent struct {
|
||||
// No mutex needed here if impl is set once at construction
|
||||
// and the implementation handles its own internal state synchronization.
|
||||
impl mcpImplementation
|
||||
|
||||
// Shared Wazero resources (runtime, cache) are managed externally
|
||||
// and closed separately, likely during application shutdown.
|
||||
}
|
||||
|
||||
// mcpConstructor creates the appropriate MCP implementation (native or WASM)
|
||||
// and wraps it in the MCPAgent.
|
||||
func mcpConstructor(ds model.DataStore) agents.Interface {
|
||||
if _, err := os.Stat(McpServerPath); os.IsNotExist(err) {
|
||||
log.Warn("MCP server executable/WASM not found, disabling agent", "path", McpServerPath, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var agentImpl mcpImplementation
|
||||
var err error
|
||||
|
||||
if strings.HasSuffix(McpServerPath, ".wasm") {
|
||||
log.Info("Configuring MCP agent for WASM execution", "path", McpServerPath)
|
||||
ctx := context.Background()
|
||||
|
||||
// Setup Shared Wazero Resources
|
||||
var cache wazero.CompilationCache
|
||||
cacheDir := filepath.Join(conf.Server.DataFolder, "cache", "wazero")
|
||||
if errMkdir := os.MkdirAll(cacheDir, 0755); errMkdir != nil {
|
||||
log.Error(ctx, "Failed to create Wazero cache directory, WASM caching disabled", "path", cacheDir, "error", errMkdir)
|
||||
} else {
|
||||
cache, err = wazero.NewCompilationCacheWithDir(cacheDir)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Failed to create Wazero compilation cache, WASM caching disabled", "path", cacheDir, "error", err)
|
||||
cache = nil
|
||||
}
|
||||
}
|
||||
|
||||
runtimeConfig := wazero.NewRuntimeConfig()
|
||||
if cache != nil {
|
||||
runtimeConfig = runtimeConfig.WithCompilationCache(cache)
|
||||
}
|
||||
|
||||
runtime := wazero.NewRuntimeWithConfig(ctx, runtimeConfig)
|
||||
|
||||
if err = registerHostFunctions(ctx, runtime); err != nil {
|
||||
_ = runtime.Close(ctx)
|
||||
if cache != nil {
|
||||
_ = cache.Close(ctx)
|
||||
}
|
||||
return nil // Fatal error: Host functions required
|
||||
}
|
||||
|
||||
if _, err = wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
|
||||
log.Error(ctx, "Failed to instantiate WASI on shared Wazero runtime, MCP WASM agent disabled", "error", err)
|
||||
_ = runtime.Close(ctx)
|
||||
if cache != nil {
|
||||
_ = cache.Close(ctx)
|
||||
}
|
||||
return nil // Fatal error: WASI required
|
||||
}
|
||||
|
||||
// Compile the module
|
||||
log.Debug(ctx, "Pre-compiling WASM module...", "path", McpServerPath)
|
||||
wasmBytes, err := os.ReadFile(McpServerPath)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Failed to read WASM module file, disabling agent", "path", McpServerPath, "error", err)
|
||||
_ = runtime.Close(ctx)
|
||||
if cache != nil {
|
||||
_ = cache.Close(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
compiledModule, err := runtime.CompileModule(ctx, wasmBytes)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Failed to pre-compile WASM module, disabling agent", "path", McpServerPath, "error", err)
|
||||
_ = runtime.Close(ctx)
|
||||
if cache != nil {
|
||||
_ = cache.Close(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
agentImpl = newMCPWasm(runtime, cache, compiledModule)
|
||||
log.Info(ctx, "Shared Wazero runtime, WASI, cache, host functions initialized, and module pre-compiled for MCP agent")
|
||||
|
||||
} else {
|
||||
log.Info("Configuring MCP agent for native execution", "path", McpServerPath)
|
||||
agentImpl = newMCPNative()
|
||||
}
|
||||
|
||||
log.Info("MCP Agent implementation created successfully")
|
||||
return &MCPAgent{impl: agentImpl}
|
||||
}
|
||||
|
||||
// NewAgentForTesting is a constructor specifically for tests.
|
||||
// It creates the appropriate implementation based on McpServerPath
|
||||
// and injects a mock mcpClient into its ClientOverride field.
|
||||
func NewAgentForTesting(mockClient mcpClient) agents.Interface {
|
||||
// We need to replicate the logic from mcpConstructor to determine
|
||||
// the implementation type, but without actually starting processes.
|
||||
|
||||
var agentImpl mcpImplementation
|
||||
|
||||
if strings.HasSuffix(McpServerPath, ".wasm") {
|
||||
// For WASM testing, we might not need the full runtime setup,
|
||||
// just the struct. Pass nil for shared resources for now.
|
||||
// We rely on the mockClient being used before any real WASM interaction.
|
||||
wasmImpl := newMCPWasm(nil, nil, nil)
|
||||
wasmImpl.ClientOverride = mockClient
|
||||
agentImpl = wasmImpl
|
||||
} else {
|
||||
nativeImpl := newMCPNative()
|
||||
nativeImpl.ClientOverride = mockClient
|
||||
agentImpl = nativeImpl
|
||||
}
|
||||
|
||||
return &MCPAgent{impl: agentImpl}
|
||||
}
|
||||
|
||||
func (a *MCPAgent) AgentName() string {
|
||||
return McpAgentName
|
||||
}
|
||||
|
||||
func (a *MCPAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if a.impl == nil {
|
||||
return "", errors.New("MCP agent implementation is nil")
|
||||
}
|
||||
// Construct args and call the implementation's specific tool caller
|
||||
args := ArtistArgs{ID: id, Name: name, Mbid: mbid}
|
||||
return a.impl.callMCPTool(ctx, McpToolNameGetBio, args)
|
||||
}
|
||||
|
||||
func (a *MCPAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if a.impl == nil {
|
||||
return "", errors.New("MCP agent implementation is nil")
|
||||
}
|
||||
// Construct args and call the implementation's specific tool caller
|
||||
args := ArtistArgs{ID: id, Name: name, Mbid: mbid}
|
||||
return a.impl.callMCPTool(ctx, McpToolNameGetURL, args)
|
||||
}
|
||||
|
||||
// Note: A Close method on MCPAgent itself isn't part of agents.Interface.
|
||||
// Cleanup of the specific implementation happens via impl.Close().
|
||||
// Cleanup of shared Wazero resources needs separate handling (e.g., on app shutdown).
|
||||
|
||||
// ArtistArgs defines the structure for MCP tool arguments requiring artist info.
|
||||
type ArtistArgs struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Mbid string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
var _ agents.ArtistBiographyRetriever = (*MCPAgent)(nil)
|
||||
var _ agents.ArtistURLRetriever = (*MCPAgent)(nil)
|
||||
|
||||
func init() {
|
||||
agents.Register(McpAgentName, mcpConstructor)
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
mcp_client "github.com/metoro-io/mcp-golang" // Renamed alias for clarity
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/agents/mcp"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Define the mcpClient interface locally for mocking, matching the one
|
||||
// used internally by MCPNative/MCPWasm.
|
||||
type mcpClient interface {
|
||||
Initialize(ctx context.Context) (*mcp_client.InitializeResponse, error)
|
||||
CallTool(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error)
|
||||
}
|
||||
|
||||
// mockMCPClient is a mock implementation of mcpClient for testing.
|
||||
type mockMCPClient struct {
|
||||
InitializeFunc func(ctx context.Context) (*mcp_client.InitializeResponse, error)
|
||||
CallToolFunc func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error)
|
||||
callToolArgs []any // Store args for verification
|
||||
callToolName string // Store tool name for verification
|
||||
}
|
||||
|
||||
func (m *mockMCPClient) Initialize(ctx context.Context) (*mcp_client.InitializeResponse, error) {
|
||||
if m.InitializeFunc != nil {
|
||||
return m.InitializeFunc(ctx)
|
||||
}
|
||||
return &mcp_client.InitializeResponse{}, nil // Default success
|
||||
}
|
||||
|
||||
func (m *mockMCPClient) CallTool(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
m.callToolName = toolName
|
||||
m.callToolArgs = append(m.callToolArgs, args)
|
||||
if m.CallToolFunc != nil {
|
||||
return m.CallToolFunc(ctx, toolName, args)
|
||||
}
|
||||
return &mcp_client.ToolResponse{}, nil
|
||||
}
|
||||
|
||||
// Ensure mock implements the local interface (compile-time check)
|
||||
var _ mcpClient = (*mockMCPClient)(nil)
|
||||
|
||||
var _ = Describe("MCPAgent", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
// We test the public MCPAgent wrapper, which uses the implementations internally.
|
||||
// The actual agent instance might be native or wasm depending on McpServerPath
|
||||
agent agents.Interface // Use the public agents.Interface
|
||||
mockClient *mockMCPClient
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
mockClient = &mockMCPClient{
|
||||
callToolArgs: make([]any, 0), // Reset args on each test
|
||||
}
|
||||
|
||||
// Instantiate the real agent using a testing constructor
|
||||
// This constructor needs to be added to the mcp package.
|
||||
agent = mcp.NewAgentForTesting(mockClient)
|
||||
Expect(agent).NotTo(BeNil(), "Agent should be created")
|
||||
})
|
||||
|
||||
// Helper to get the concrete agent type for calling specific methods
|
||||
getConcreteAgent := func() *mcp.MCPAgent {
|
||||
concreteAgent, ok := agent.(*mcp.MCPAgent)
|
||||
Expect(ok).To(BeTrue(), "Agent should be of type *mcp.MCPAgent")
|
||||
return concreteAgent
|
||||
}
|
||||
|
||||
Describe("GetArtistBiography", func() {
|
||||
It("should call the correct tool and return the biography", func() {
|
||||
expectedBio := "This is the artist bio."
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
Expect(toolName).To(Equal(mcp.McpToolNameGetBio))
|
||||
Expect(args).To(BeAssignableToTypeOf(mcp.ArtistArgs{}))
|
||||
typedArgs := args.(mcp.ArtistArgs)
|
||||
Expect(typedArgs.ID).To(Equal("id1"))
|
||||
Expect(typedArgs.Name).To(Equal("Artist Name"))
|
||||
Expect(typedArgs.Mbid).To(Equal("mbid1"))
|
||||
return mcp_client.NewToolResponse(mcp_client.NewTextContent(expectedBio)), nil
|
||||
}
|
||||
|
||||
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(bio).To(Equal(expectedBio))
|
||||
})
|
||||
|
||||
It("should return error if CallTool fails", func() {
|
||||
expectedErr := errors.New("mcp tool error")
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
return nil, expectedErr
|
||||
}
|
||||
|
||||
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
|
||||
Expect(err).To(HaveOccurred())
|
||||
// The error originates from the implementation now, check for specific part
|
||||
Expect(err.Error()).To(SatisfyAny(
|
||||
ContainSubstring("native MCP agent not ready"), // Error from native
|
||||
ContainSubstring("WASM MCP agent not ready"), // Error from WASM
|
||||
ContainSubstring("failed to call native MCP tool"),
|
||||
ContainSubstring("failed to call WASM MCP tool"),
|
||||
))
|
||||
Expect(errors.Is(err, expectedErr)).To(BeTrue())
|
||||
Expect(bio).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return ErrNotFound if CallTool response is empty", func() {
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
// Return a response created with no content parts
|
||||
return mcp_client.NewToolResponse(), nil
|
||||
}
|
||||
|
||||
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
Expect(bio).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return ErrNotFound if CallTool response has nil TextContent (simulated by empty string)", func() {
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
// Simulate nil/empty text content by creating response with empty string text
|
||||
return mcp_client.NewToolResponse(mcp_client.NewTextContent("")), nil
|
||||
}
|
||||
|
||||
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
Expect(bio).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return comm error if CallTool returns pipe error", func() {
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
return nil, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(SatisfyAny(
|
||||
ContainSubstring("native MCP agent process communication error"),
|
||||
ContainSubstring("WASM MCP agent module communication error"),
|
||||
))
|
||||
Expect(errors.Is(err, io.ErrClosedPipe)).To(BeTrue())
|
||||
Expect(bio).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return ErrNotFound if MCP tool returns an error string", func() {
|
||||
mcpErrorString := "handler returned an error: something went wrong on the server"
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
return mcp_client.NewToolResponse(mcp_client.NewTextContent(mcpErrorString)), nil
|
||||
}
|
||||
|
||||
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
Expect(bio).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistURL", func() {
|
||||
It("should call the correct tool and return the URL", func() {
|
||||
expectedURL := "http://example.com/artist"
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
Expect(toolName).To(Equal(mcp.McpToolNameGetURL))
|
||||
Expect(args).To(BeAssignableToTypeOf(mcp.ArtistArgs{}))
|
||||
typedArgs := args.(mcp.ArtistArgs)
|
||||
Expect(typedArgs.ID).To(Equal("id2"))
|
||||
Expect(typedArgs.Name).To(Equal("Another Artist"))
|
||||
Expect(typedArgs.Mbid).To(Equal("mbid2"))
|
||||
return mcp_client.NewToolResponse(mcp_client.NewTextContent(expectedURL)), nil
|
||||
}
|
||||
|
||||
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(url).To(Equal(expectedURL))
|
||||
})
|
||||
|
||||
It("should return error if CallTool fails", func() {
|
||||
expectedErr := errors.New("mcp tool error url")
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
return nil, expectedErr
|
||||
}
|
||||
|
||||
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(SatisfyAny(
|
||||
ContainSubstring("native MCP agent not ready"), // Error from native
|
||||
ContainSubstring("WASM MCP agent not ready"), // Error from WASM
|
||||
ContainSubstring("failed to call native MCP tool"),
|
||||
ContainSubstring("failed to call WASM MCP tool"),
|
||||
))
|
||||
Expect(errors.Is(err, expectedErr)).To(BeTrue())
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return ErrNotFound if CallTool response is empty", func() {
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
// Return a response created with no content parts
|
||||
return mcp_client.NewToolResponse(), nil
|
||||
}
|
||||
|
||||
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return comm error if CallTool returns pipe error", func() {
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
return nil, fmt.Errorf("write: %w", io.ErrClosedPipe)
|
||||
}
|
||||
|
||||
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(SatisfyAny(
|
||||
ContainSubstring("native MCP agent process communication error"),
|
||||
ContainSubstring("WASM MCP agent module communication error"),
|
||||
))
|
||||
Expect(errors.Is(err, io.ErrClosedPipe)).To(BeTrue())
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return ErrNotFound if MCP tool returns an error string", func() {
|
||||
mcpErrorString := "handler returned an error: could not find url"
|
||||
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
|
||||
return mcp_client.NewToolResponse(mcp_client.NewTextContent(mcpErrorString)), nil
|
||||
}
|
||||
|
||||
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,189 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
// httpClient is a shared HTTP client for host function reuse.
|
||||
var httpClient = &http.Client{
|
||||
// Consider adding a default timeout
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// registerHostFunctions defines and registers the host functions (e.g., http_fetch)
|
||||
// into the provided Wazero runtime.
|
||||
func registerHostFunctions(ctx context.Context, runtime wazero.Runtime) error {
|
||||
// Define and Instantiate Host Module "env"
|
||||
_, err := runtime.NewHostModuleBuilder("env"). // "env" is the conventional module name
|
||||
NewFunctionBuilder().
|
||||
WithFunc(httpFetch). // Register our Go function
|
||||
Export("http_fetch"). // Export it with the name WASM will use
|
||||
Instantiate(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Failed to instantiate 'env' host module with httpFetch", "error", err)
|
||||
return fmt.Errorf("instantiate host module 'env': %w", err)
|
||||
}
|
||||
log.Info(ctx, "Instantiated 'env' host module with http_fetch function")
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpFetch is the host function exposed to WASM.
|
||||
// ... (full implementation as provided previously) ...
|
||||
// Returns:
|
||||
// - 0 on success (request completed, results written).
|
||||
// - 1 on host-side failure (e.g., memory access error, invalid input).
|
||||
func httpFetch(
|
||||
ctx context.Context, mod api.Module, // Standard Wazero host function params
|
||||
// Request details
|
||||
urlPtr, urlLen uint32,
|
||||
methodPtr, methodLen uint32,
|
||||
bodyPtr, bodyLen uint32,
|
||||
timeoutMillis uint32,
|
||||
// Result pointers
|
||||
resultStatusPtr uint32,
|
||||
resultBodyPtr uint32, resultBodyCapacity uint32, resultBodyLenPtr uint32,
|
||||
resultErrorPtr uint32, resultErrorCapacity uint32, resultErrorLenPtr uint32,
|
||||
) uint32 { // Using uint32 for status code convention (0=success, 1=failure)
|
||||
mem := mod.Memory()
|
||||
|
||||
// --- Read Inputs ---
|
||||
urlBytes, ok := mem.Read(urlPtr, urlLen)
|
||||
if !ok {
|
||||
log.Error(ctx, "httpFetch host error: failed to read URL from WASM memory")
|
||||
// Cannot write error back as we don't have the pointers validated yet
|
||||
return 1
|
||||
}
|
||||
url := string(urlBytes)
|
||||
|
||||
methodBytes, ok := mem.Read(methodPtr, methodLen)
|
||||
if !ok {
|
||||
log.Error(ctx, "httpFetch host error: failed to read method from WASM memory", "url", url)
|
||||
return 1 // Bail out
|
||||
}
|
||||
method := string(methodBytes)
|
||||
if method == "" {
|
||||
method = "GET" // Default to GET
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if bodyLen > 0 {
|
||||
bodyBytes, ok := mem.Read(bodyPtr, bodyLen)
|
||||
if !ok {
|
||||
log.Error(ctx, "httpFetch host error: failed to read body from WASM memory", "url", url, "method", method)
|
||||
return 1 // Bail out
|
||||
}
|
||||
reqBody = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
|
||||
timeout := time.Duration(timeoutMillis) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second // Default timeout matching httpClient
|
||||
}
|
||||
|
||||
// --- Prepare and Execute Request ---
|
||||
log.Debug(ctx, "httpFetch executing request", "method", method, "url", url, "timeout", timeout)
|
||||
|
||||
// Use a specific context for the request, derived from the host function's context
|
||||
// but with the specific timeout for this call.
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, method, url, reqBody)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to create request: %v", err)
|
||||
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "error", errMsg)
|
||||
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
|
||||
mem.WriteUint32Le(resultStatusPtr, 0) // Write 0 status on creation error
|
||||
mem.WriteUint32Le(resultBodyLenPtr, 0) // No body
|
||||
return 0 // Indicate results (including error) were written
|
||||
}
|
||||
|
||||
// TODO: Consider adding a User-Agent?
|
||||
// req.Header.Set("User-Agent", "Navidrome/MCP-Agent-Host")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Handle client-side errors (network, DNS, timeout)
|
||||
errMsg := fmt.Sprintf("failed to execute request: %v", err)
|
||||
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "error", errMsg)
|
||||
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
|
||||
mem.WriteUint32Le(resultStatusPtr, 0) // Write 0 status on transport error
|
||||
mem.WriteUint32Le(resultBodyLenPtr, 0)
|
||||
return 0 // Indicate results written
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// --- Process Response ---
|
||||
statusCode := uint32(resp.StatusCode)
|
||||
responseBodyBytes, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
errMsg := fmt.Sprintf("failed to read response body: %v", readErr)
|
||||
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "status", statusCode, "error", errMsg)
|
||||
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
|
||||
mem.WriteUint32Le(resultStatusPtr, statusCode) // Write actual status code
|
||||
mem.WriteUint32Le(resultBodyLenPtr, 0)
|
||||
return 0 // Indicate results written
|
||||
}
|
||||
|
||||
// --- Write Results Back to WASM Memory ---
|
||||
log.Debug(ctx, "httpFetch writing results", "url", url, "method", method, "status", statusCode, "bodyLen", len(responseBodyBytes))
|
||||
|
||||
// Write status code
|
||||
if !mem.WriteUint32Le(resultStatusPtr, statusCode) {
|
||||
log.Error(ctx, "httpFetch host error: failed to write status code to WASM memory")
|
||||
return 1 // Host error
|
||||
}
|
||||
|
||||
// Write response body (checking capacity)
|
||||
if !writeBytesResult(mem, resultBodyPtr, resultBodyCapacity, resultBodyLenPtr, responseBodyBytes) {
|
||||
// If body write fails (likely due to capacity), write an error message instead.
|
||||
errMsg := fmt.Sprintf("response body size (%d) exceeds buffer capacity (%d)", len(responseBodyBytes), resultBodyCapacity)
|
||||
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "status", statusCode, "error", errMsg)
|
||||
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
|
||||
mem.WriteUint32Le(resultBodyLenPtr, 0) // Ensure body length is 0 if we wrote an error
|
||||
} else {
|
||||
// Write empty error string if body write was successful
|
||||
mem.WriteUint32Le(resultErrorLenPtr, 0)
|
||||
}
|
||||
|
||||
return 0 // Success
|
||||
}
|
||||
|
||||
// Helper to write string results, respecting capacity. Returns true on success.
|
||||
func writeStringResult(mem api.Memory, ptr, capacity, lenPtr uint32, result string) bool {
|
||||
bytes := []byte(result)
|
||||
return writeBytesResult(mem, ptr, capacity, lenPtr, bytes)
|
||||
}
|
||||
|
||||
// Helper to write byte results, respecting capacity. Returns true on success.
|
||||
func writeBytesResult(mem api.Memory, ptr, capacity, lenPtr uint32, result []byte) bool {
|
||||
resultLen := uint32(len(result))
|
||||
writeLen := resultLen
|
||||
if writeLen > capacity {
|
||||
log.Warn(context.Background(), "WASM host write truncated", "requested", resultLen, "capacity", capacity)
|
||||
writeLen = capacity // Truncate if too large for buffer
|
||||
}
|
||||
|
||||
if writeLen > 0 {
|
||||
if !mem.Write(ptr, result[:writeLen]) {
|
||||
log.Error(context.Background(), "WASM host memory write failed", "ptr", ptr, "len", writeLen)
|
||||
return false // Memory write failed
|
||||
}
|
||||
}
|
||||
|
||||
// Write the *original* length of the data (even if truncated) so the WASM side knows.
|
||||
if !mem.WriteUint32Le(lenPtr, resultLen) {
|
||||
log.Error(context.Background(), "WASM host memory length write failed", "lenPtr", lenPtr, "len", resultLen)
|
||||
return false // Memory write failed
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
mcp "github.com/metoro-io/mcp-golang"
|
||||
"github.com/metoro-io/mcp-golang/transport/stdio"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// MCPNative implements the mcpImplementation interface for running the MCP server as a native process.
|
||||
type MCPNative struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd // Stores the running command
|
||||
stdin io.WriteCloser
|
||||
client mcpClient
|
||||
|
||||
// ClientOverride allows injecting a mock client for testing this specific implementation.
|
||||
ClientOverride mcpClient // TODO: Consider if this is the best way to test
|
||||
}
|
||||
|
||||
// newMCPNative creates a new instance of the native MCP agent implementation.
|
||||
func newMCPNative() *MCPNative {
|
||||
return &MCPNative{}
|
||||
}
|
||||
|
||||
// --- mcpImplementation interface methods ---
|
||||
|
||||
func (n *MCPNative) Close() error {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.cleanupResources_locked()
|
||||
return nil // Currently, cleanup doesn't return errors
|
||||
}
|
||||
|
||||
// --- Internal Helper Methods ---
|
||||
|
||||
// ensureClientInitialized starts the MCP server process and initializes the client if needed.
|
||||
// MUST be called with the mutex HELD.
|
||||
func (n *MCPNative) ensureClientInitialized_locked(ctx context.Context) error {
|
||||
// Use override if provided (for testing)
|
||||
if n.ClientOverride != nil {
|
||||
if n.client == nil {
|
||||
n.client = n.ClientOverride
|
||||
log.Debug(ctx, "Using provided MCP client override for native testing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if already initialized
|
||||
if n.client != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info(ctx, "Initializing Native MCP client and starting/restarting server process...", "serverPath", McpServerPath)
|
||||
|
||||
// Clean up any old resources *before* starting new ones
|
||||
n.cleanupResources_locked()
|
||||
|
||||
hostStdinWriter, hostStdoutReader, nativeCmd, startErr := n.startProcess_locked(ctx)
|
||||
if startErr != nil {
|
||||
log.Error(ctx, "Failed to start Native MCP server process", "error", startErr)
|
||||
// Ensure pipes are closed if start failed (startProcess might handle this, but be sure)
|
||||
if hostStdinWriter != nil {
|
||||
_ = hostStdinWriter.Close()
|
||||
}
|
||||
if hostStdoutReader != nil {
|
||||
_ = hostStdoutReader.Close()
|
||||
}
|
||||
return fmt.Errorf("failed to start native MCP server: %w", startErr)
|
||||
}
|
||||
|
||||
// --- Initialize MCP client ---
|
||||
transport := stdio.NewStdioServerTransportWithIO(hostStdoutReader, hostStdinWriter)
|
||||
clientImpl := mcp.NewClient(transport)
|
||||
|
||||
initCtx, cancel := context.WithTimeout(context.Background(), initializationTimeout)
|
||||
defer cancel()
|
||||
if _, initErr := clientImpl.Initialize(initCtx); initErr != nil {
|
||||
err := fmt.Errorf("failed to initialize native MCP client: %w", initErr)
|
||||
log.Error(ctx, "Native MCP client initialization failed", "error", err)
|
||||
// Cleanup the newly started process and close pipes
|
||||
n.cmd = nativeCmd // Temporarily set cmd so cleanup can kill it
|
||||
n.cleanupResources_locked()
|
||||
if hostStdinWriter != nil {
|
||||
_ = hostStdinWriter.Close()
|
||||
}
|
||||
if hostStdoutReader != nil {
|
||||
_ = hostStdoutReader.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Initialization successful, update agent state ---
|
||||
n.cmd = nativeCmd
|
||||
n.stdin = hostStdinWriter // This is the pipe the agent writes to
|
||||
n.client = clientImpl
|
||||
|
||||
log.Info(ctx, "Native MCP client initialized successfully", "pid", n.cmd.Process.Pid)
|
||||
return nil // Success
|
||||
}
|
||||
|
||||
// callMCPTool handles ensuring initialization and calling the MCP tool.
|
||||
func (n *MCPNative) callMCPTool(ctx context.Context, toolName string, args any) (string, error) {
|
||||
// Ensure the client is initialized and the server is running (attempts restart if needed)
|
||||
n.mu.Lock()
|
||||
err := n.ensureClientInitialized_locked(ctx)
|
||||
if err != nil {
|
||||
n.mu.Unlock()
|
||||
log.Error(ctx, "Native MCP agent initialization/restart failed", "tool", toolName, "error", err)
|
||||
return "", fmt.Errorf("native MCP agent not ready: %w", err)
|
||||
}
|
||||
|
||||
// Keep a reference to the client while locked
|
||||
currentClient := n.client
|
||||
// Unlock mutex *before* making the potentially blocking MCP call
|
||||
n.mu.Unlock()
|
||||
|
||||
// Call the tool using the client reference
|
||||
log.Debug(ctx, "Calling Native MCP tool", "tool", toolName, "args", args)
|
||||
response, callErr := currentClient.CallTool(ctx, toolName, args)
|
||||
if callErr != nil {
|
||||
// Handle potential pipe closures or other communication errors
|
||||
log.Error(ctx, "Failed to call Native MCP tool", "tool", toolName, "error", callErr)
|
||||
// Check if the error indicates a broken pipe, suggesting the server died
|
||||
// The monitoring goroutine will handle cleanup, just return error here.
|
||||
if errors.Is(callErr, io.ErrClosedPipe) || strings.Contains(callErr.Error(), "broken pipe") || strings.Contains(callErr.Error(), "EOF") {
|
||||
log.Warn(ctx, "Native MCP tool call failed, possibly due to server process exit.", "tool", toolName)
|
||||
// No need to explicitly call cleanup, monitoring goroutine handles it.
|
||||
return "", fmt.Errorf("native MCP agent process communication error: %w", callErr)
|
||||
}
|
||||
return "", fmt.Errorf("failed to call native MCP tool '%s': %w", toolName, callErr)
|
||||
}
|
||||
|
||||
// Process the response (same logic as before)
|
||||
if response == nil || len(response.Content) == 0 || response.Content[0].TextContent == nil || response.Content[0].TextContent.Text == "" {
|
||||
log.Warn(ctx, "Native MCP tool returned empty/invalid response", "tool", toolName)
|
||||
// Treat as not found for agent interface consistency
|
||||
return "", agents.ErrNotFound // Import agents package if needed, or define locally
|
||||
}
|
||||
resultText := response.Content[0].TextContent.Text
|
||||
if strings.HasPrefix(resultText, "handler returned an error:") {
|
||||
log.Warn(ctx, "Native MCP tool returned an error message", "tool", toolName, "mcpError", resultText)
|
||||
return "", agents.ErrNotFound // Treat MCP tool errors as "not found"
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Received response from Native MCP agent", "tool", toolName, "length", len(resultText))
|
||||
return resultText, nil
|
||||
}
|
||||
|
||||
// cleanupResources closes existing resources (stdin, server process).
|
||||
// MUST be called with the mutex HELD.
|
||||
func (n *MCPNative) cleanupResources_locked() {
|
||||
log.Debug(context.Background(), "Cleaning up Native MCP instance resources...")
|
||||
if n.stdin != nil {
|
||||
_ = n.stdin.Close()
|
||||
n.stdin = nil
|
||||
}
|
||||
if n.cmd != nil && n.cmd.Process != nil {
|
||||
pid := n.cmd.Process.Pid
|
||||
log.Debug(context.Background(), "Killing native MCP process", "pid", pid)
|
||||
// Kill the process. Ignore error if it's already done.
|
||||
if err := n.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
log.Error(context.Background(), "Failed to kill native process", "pid", pid, "error", err)
|
||||
}
|
||||
// Wait for the process to release resources. Ignore error.
|
||||
_ = n.cmd.Wait()
|
||||
n.cmd = nil
|
||||
}
|
||||
// Mark client as invalid
|
||||
n.client = nil
|
||||
}
|
||||
|
||||
// startProcess starts the MCP server as a native executable and sets up monitoring.
|
||||
// MUST be called with the mutex HELD.
|
||||
func (n *MCPNative) startProcess_locked(ctx context.Context) (stdin io.WriteCloser, stdout io.ReadCloser, cmd *exec.Cmd, err error) {
|
||||
log.Debug(ctx, "Starting native MCP server process", "path", McpServerPath)
|
||||
// Use Background context for the command itself, as it should outlive the request context (ctx)
|
||||
cmd = exec.CommandContext(context.Background(), McpServerPath)
|
||||
|
||||
stdinPipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("native stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = stdinPipe.Close()
|
||||
return nil, nil, nil, fmt.Errorf("native stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
// Get stderr pipe to stream logs
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
_ = stdinPipe.Close()
|
||||
_ = stdoutPipe.Close()
|
||||
return nil, nil, nil, fmt.Errorf("native stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
_ = stdinPipe.Close()
|
||||
_ = stdoutPipe.Close()
|
||||
// stderrPipe gets closed implicitly if cmd.Start() fails
|
||||
return nil, nil, nil, fmt.Errorf("native start: %w", err)
|
||||
}
|
||||
|
||||
currentPid := cmd.Process.Pid
|
||||
currentCmd := cmd // Capture the current cmd pointer for the goroutine
|
||||
log.Info(ctx, "Native MCP server process started", "pid", currentPid)
|
||||
|
||||
// Start monitoring goroutine for process exit
|
||||
go func() {
|
||||
// Start separate goroutine to stream stderr
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stderrPipe)
|
||||
for scanner.Scan() {
|
||||
log.Info("[MCP-SERVER] " + scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Error("Error reading MCP server stderr", "pid", currentPid, "error", err)
|
||||
}
|
||||
log.Debug("MCP server stderr pipe closed", "pid", currentPid)
|
||||
}()
|
||||
|
||||
waitErr := currentCmd.Wait() // Wait for the specific process this goroutine monitors
|
||||
n.mu.Lock()
|
||||
// Stderr is now streamed, so we don't capture it here anymore.
|
||||
log.Warn("Native MCP server process exited", "pid", currentPid, "error", waitErr)
|
||||
|
||||
// Critical: Check if the agent's current command is STILL the one we were monitoring.
|
||||
// If it's different, it means cleanup/restart already happened, so we shouldn't cleanup again.
|
||||
if n.cmd == currentCmd {
|
||||
n.cleanupResources_locked() // Use the locked version as we hold the lock
|
||||
log.Info("MCP Native agent state cleaned up after process exit", "pid", currentPid)
|
||||
} else {
|
||||
log.Debug("Native MCP process exited, but state already updated/cmd mismatch", "exitedPid", currentPid)
|
||||
}
|
||||
n.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Return the pipes connected to the process and the Cmd object
|
||||
return stdinPipe, stdoutPipe, cmd, nil
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mcp "github.com/metoro-io/mcp-golang"
|
||||
"github.com/metoro-io/mcp-golang/transport/stdio"
|
||||
"github.com/navidrome/navidrome/core/agents" // Needed for ErrNotFound
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/tetratelabs/wazero" // Needed for types
|
||||
"github.com/tetratelabs/wazero/api" // Needed for types
|
||||
)
|
||||
|
||||
// MCPWasm implements the mcpImplementation interface for running the MCP server as a WASM module.
|
||||
type MCPWasm struct {
|
||||
mu sync.Mutex
|
||||
wasmModule api.Module // Stores the instantiated module
|
||||
wasmCompiled api.Closer // Stores the compiled module reference for this instance
|
||||
stdin io.WriteCloser
|
||||
client mcpClient
|
||||
|
||||
// Shared resources (passed in, not owned by this struct)
|
||||
wasmRuntime api.Closer // Shared Wazero Runtime
|
||||
wasmCache wazero.CompilationCache // Shared Compilation Cache (can be nil)
|
||||
preCompiledModule wazero.CompiledModule // Pre-compiled module from constructor
|
||||
|
||||
// ClientOverride allows injecting a mock client for testing this specific implementation.
|
||||
ClientOverride mcpClient // TODO: Consider if this is the best way to test
|
||||
}
|
||||
|
||||
// newMCPWasm creates a new instance of the WASM MCP agent implementation.
|
||||
// It stores the shared runtime, cache, and the pre-compiled module.
|
||||
func newMCPWasm(runtime api.Closer, cache wazero.CompilationCache, compiledModule wazero.CompiledModule) *MCPWasm {
|
||||
return &MCPWasm{
|
||||
wasmRuntime: runtime,
|
||||
wasmCache: cache,
|
||||
preCompiledModule: compiledModule,
|
||||
}
|
||||
}
|
||||
|
||||
// --- mcpImplementation interface methods ---
|
||||
|
||||
// Close cleans up instance-specific WASM resources.
|
||||
// It does NOT close the shared runtime or cache.
|
||||
func (w *MCPWasm) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.cleanupResources_locked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Internal Helper Methods ---
|
||||
|
||||
// ensureClientInitialized starts the MCP WASM module and initializes the client if needed.
|
||||
// MUST be called with the mutex HELD.
|
||||
func (w *MCPWasm) ensureClientInitialized_locked(ctx context.Context) error {
|
||||
// Use override if provided (for testing)
|
||||
if w.ClientOverride != nil {
|
||||
if w.client == nil {
|
||||
w.client = w.ClientOverride
|
||||
log.Debug(ctx, "Using provided MCP client override for WASM testing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if already initialized
|
||||
if w.client != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info(ctx, "Initializing WASM MCP client and starting/restarting server module...", "serverPath", McpServerPath)
|
||||
|
||||
w.cleanupResources_locked()
|
||||
|
||||
// Check if shared runtime exists
|
||||
if w.wasmRuntime == nil {
|
||||
return errors.New("shared Wazero runtime not initialized for MCPWasm")
|
||||
}
|
||||
|
||||
hostStdinWriter, hostStdoutReader, mod, err := w.startModule_locked(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Failed to start WASM MCP server module", "error", err)
|
||||
// Ensure pipes are closed if start failed
|
||||
if hostStdinWriter != nil {
|
||||
_ = hostStdinWriter.Close()
|
||||
}
|
||||
if hostStdoutReader != nil {
|
||||
_ = hostStdoutReader.Close()
|
||||
}
|
||||
// startModule_locked handles cleanup of mod/compiled on error
|
||||
return fmt.Errorf("failed to start WASM MCP server: %w", err)
|
||||
}
|
||||
|
||||
transport := stdio.NewStdioServerTransportWithIO(hostStdoutReader, hostStdinWriter)
|
||||
clientImpl := mcp.NewClient(transport)
|
||||
|
||||
initCtx, cancel := context.WithTimeout(context.Background(), initializationTimeout)
|
||||
defer cancel()
|
||||
if _, initErr := clientImpl.Initialize(initCtx); initErr != nil {
|
||||
err := fmt.Errorf("failed to initialize WASM MCP client: %w", initErr)
|
||||
log.Error(ctx, "WASM MCP client initialization failed", "error", err)
|
||||
// Cleanup the newly started module and close pipes
|
||||
w.wasmModule = mod // Temporarily set so cleanup can close it
|
||||
w.wasmCompiled = nil // We don't store the compiled instance ref anymore, just the module instance
|
||||
w.cleanupResources_locked()
|
||||
if hostStdinWriter != nil {
|
||||
_ = hostStdinWriter.Close()
|
||||
}
|
||||
if hostStdoutReader != nil {
|
||||
_ = hostStdoutReader.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
w.wasmModule = mod
|
||||
w.wasmCompiled = nil // We don't store the compiled instance ref anymore, just the module instance
|
||||
w.stdin = hostStdinWriter
|
||||
w.client = clientImpl
|
||||
|
||||
log.Info(ctx, "WASM MCP client initialized successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// callMCPTool handles ensuring initialization and calling the MCP tool.
|
||||
func (w *MCPWasm) callMCPTool(ctx context.Context, toolName string, args any) (string, error) {
|
||||
w.mu.Lock()
|
||||
err := w.ensureClientInitialized_locked(ctx)
|
||||
if err != nil {
|
||||
w.mu.Unlock()
|
||||
log.Error(ctx, "WASM MCP agent initialization/restart failed", "tool", toolName, "error", err)
|
||||
return "", fmt.Errorf("WASM MCP agent not ready: %w", err)
|
||||
}
|
||||
|
||||
// Keep a reference to the client while locked
|
||||
currentClient := w.client
|
||||
// Unlock mutex *before* making the potentially blocking MCP call
|
||||
w.mu.Unlock()
|
||||
|
||||
// Call the tool using the client reference
|
||||
log.Debug(ctx, "Calling WASM MCP tool", "tool", toolName, "args", args)
|
||||
response, callErr := currentClient.CallTool(ctx, toolName, args)
|
||||
if callErr != nil {
|
||||
// Handle potential pipe closures or other communication errors
|
||||
log.Error(ctx, "Failed to call WASM MCP tool", "tool", toolName, "error", callErr)
|
||||
// Check if the error indicates a broken pipe, suggesting the server died
|
||||
// The monitoring goroutine will handle cleanup, just return error here.
|
||||
if errors.Is(callErr, io.ErrClosedPipe) || strings.Contains(callErr.Error(), "broken pipe") || strings.Contains(callErr.Error(), "EOF") {
|
||||
log.Warn(ctx, "WASM MCP tool call failed, possibly due to server module exit.", "tool", toolName)
|
||||
// No need to explicitly call cleanup, monitoring goroutine handles it.
|
||||
return "", fmt.Errorf("WASM MCP agent module communication error: %w", callErr)
|
||||
}
|
||||
return "", fmt.Errorf("failed to call WASM MCP tool '%s': %w", toolName, callErr)
|
||||
}
|
||||
|
||||
// Process the response (same logic as native)
|
||||
if response == nil || len(response.Content) == 0 || response.Content[0].TextContent == nil || response.Content[0].TextContent.Text == "" {
|
||||
log.Warn(ctx, "WASM MCP tool returned empty/invalid response", "tool", toolName)
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
resultText := response.Content[0].TextContent.Text
|
||||
if strings.HasPrefix(resultText, "handler returned an error:") {
|
||||
log.Warn(ctx, "WASM MCP tool returned an error message", "tool", toolName, "mcpError", resultText)
|
||||
return "", agents.ErrNotFound // Treat MCP tool errors as "not found"
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Received response from WASM MCP agent", "tool", toolName, "length", len(resultText))
|
||||
return resultText, nil
|
||||
}
|
||||
|
||||
// cleanupResources closes instance-specific WASM resources (stdin, module, compiled ref).
|
||||
// It specifically avoids closing the shared runtime or cache.
|
||||
// MUST be called with the mutex HELD.
|
||||
func (w *MCPWasm) cleanupResources_locked() {
|
||||
log.Debug(context.Background(), "Cleaning up WASM MCP instance resources...")
|
||||
if w.stdin != nil {
|
||||
_ = w.stdin.Close()
|
||||
w.stdin = nil
|
||||
}
|
||||
// Close the module instance
|
||||
if w.wasmModule != nil {
|
||||
log.Debug(context.Background(), "Closing WASM module instance")
|
||||
ctxClose, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
if err := w.wasmModule.Close(ctxClose); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Error(context.Background(), "Failed to close WASM module instance", "error", err)
|
||||
}
|
||||
cancel()
|
||||
w.wasmModule = nil
|
||||
}
|
||||
// DO NOT close w.wasmCompiled (instance ref)
|
||||
// DO NOT close w.preCompiledModule (shared pre-compiled code)
|
||||
// DO NOT CLOSE w.wasmRuntime or w.wasmCache here!
|
||||
w.client = nil
|
||||
}
|
||||
|
||||
// startModule loads and starts the MCP server as a WASM module.
|
||||
// It now uses the pre-compiled module.
|
||||
// MUST be called with the mutex HELD.
|
||||
func (w *MCPWasm) startModule_locked(ctx context.Context) (hostStdinWriter io.WriteCloser, hostStdoutReader io.ReadCloser, mod api.Module, err error) {
|
||||
// Check for pre-compiled module
|
||||
if w.preCompiledModule == nil {
|
||||
return nil, nil, nil, errors.New("pre-compiled WASM module is nil")
|
||||
}
|
||||
|
||||
// Create pipes for stdio redirection
|
||||
wasmStdinReader, hostStdinWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("wasm stdin pipe: %w", err)
|
||||
}
|
||||
// Defer close pipes on error exit
|
||||
shouldClosePipesOnError := true
|
||||
defer func() {
|
||||
if shouldClosePipesOnError {
|
||||
if wasmStdinReader != nil {
|
||||
_ = wasmStdinReader.Close()
|
||||
}
|
||||
if hostStdinWriter != nil {
|
||||
_ = hostStdinWriter.Close()
|
||||
}
|
||||
// hostStdoutReader and wasmStdoutWriter handled below
|
||||
}
|
||||
}()
|
||||
|
||||
hostStdoutReader, wasmStdoutWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("wasm stdout pipe: %w", err)
|
||||
}
|
||||
// Defer close pipes on error exit
|
||||
defer func() {
|
||||
if shouldClosePipesOnError {
|
||||
if hostStdoutReader != nil {
|
||||
_ = hostStdoutReader.Close()
|
||||
}
|
||||
if wasmStdoutWriter != nil {
|
||||
_ = wasmStdoutWriter.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Use the SHARDED runtime from the agent struct
|
||||
runtime, ok := w.wasmRuntime.(wazero.Runtime)
|
||||
if !ok || runtime == nil {
|
||||
return nil, nil, nil, errors.New("wasmRuntime is not initialized or not a wazero.Runtime")
|
||||
}
|
||||
|
||||
// Prepare module configuration (host funcs/WASI already instantiated on runtime)
|
||||
config := wazero.NewModuleConfig().
|
||||
WithStdin(wasmStdinReader).
|
||||
WithStdout(wasmStdoutWriter).
|
||||
WithStderr(os.Stderr).
|
||||
WithArgs(McpServerPath).
|
||||
WithFS(os.DirFS("/")) // Keep FS access for now
|
||||
|
||||
log.Info(ctx, "Instantiating pre-compiled WASM module (will run _start)...")
|
||||
var moduleInstance api.Module
|
||||
instanceErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
var instantiateErr error
|
||||
// Use context.Background() for the module's main execution context
|
||||
moduleInstance, instantiateErr = runtime.InstantiateModule(context.Background(), w.preCompiledModule, config)
|
||||
instanceErrChan <- instantiateErr
|
||||
}()
|
||||
|
||||
// Wait briefly for immediate instantiation errors
|
||||
select {
|
||||
case instantiateErr := <-instanceErrChan:
|
||||
if instantiateErr != nil {
|
||||
log.Error(ctx, "Failed to instantiate pre-compiled WASM module", "error", instantiateErr)
|
||||
// Pipes closed by defer
|
||||
return nil, nil, nil, fmt.Errorf("instantiate wasm module: %w", instantiateErr)
|
||||
}
|
||||
log.Warn(ctx, "WASM module instantiation returned (exited?) immediately without error.")
|
||||
case <-time.After(1 * time.Second): // Shorter wait now, instantiation should be faster
|
||||
log.Debug(ctx, "WASM module instantiation likely blocking (server running), proceeding...")
|
||||
}
|
||||
|
||||
// Start a monitoring goroutine for WASM module exit/error
|
||||
go func(instanceToMonitor api.Module, errChan chan error) {
|
||||
// This blocks until the instance created by InstantiateModule exits or errors.
|
||||
instantiateErr := <-errChan
|
||||
|
||||
w.mu.Lock() // Lock the specific MCPWasm instance
|
||||
log.Warn("WASM module exited/errored", "error", instantiateErr)
|
||||
|
||||
// Critical: Check if the agent's current module is STILL the one we were monitoring.
|
||||
if w.wasmModule == instanceToMonitor {
|
||||
w.cleanupResources_locked() // Use the locked version
|
||||
log.Info("MCP WASM agent state cleaned up after module exit/error")
|
||||
} else {
|
||||
log.Debug("WASM module exited, but state already updated/module mismatch. No cleanup needed by this goroutine.")
|
||||
// No need to close anything here, the pre-compiled module is shared
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}(moduleInstance, instanceErrChan)
|
||||
|
||||
// Success: prevent deferred cleanup of pipes
|
||||
shouldClosePipesOnError = false
|
||||
return hostStdinWriter, hostStdoutReader, moduleInstance, nil
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||
return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks)
|
||||
return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
@@ -109,15 +109,40 @@ func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bi
|
||||
}
|
||||
mfs := pls.MediaFiles()
|
||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||
return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs)
|
||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
||||
}
|
||||
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error {
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
|
||||
// Add M3U file if requested
|
||||
if addM3U && len(zippedMfs) > 0 {
|
||||
plsName := sanitizeName(name)
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: plsName + ".m3u",
|
||||
Modified: mfs[0].UpdatedAt,
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error creating playlist zip entry", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write([]byte(zippedMfs.ToM3U8(plsName, false)))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error writing m3u in zip", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := z.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||
|
||||
@@ -145,9 +145,21 @@ var _ = Describe("Archiver", func() {
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(len(zr.File)).To(Equal(3))
|
||||
Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
|
||||
Expect(zr.File[2].Name).To(Equal("Test Playlist.m3u"))
|
||||
|
||||
// Verify M3U content
|
||||
m3uFile, err := zr.File[2].Open()
|
||||
Expect(err).To(BeNil())
|
||||
defer m3uFile.Close()
|
||||
|
||||
m3uContent, err := io.ReadAll(m3uFile)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
expectedM3U := "#EXTM3U\n#PLAYLIST:Test Playlist\n#EXTINF:0,AC/DC - track1\n01 - AC_DC - track1.mp3\n#EXTINF:0,Artist 2 - track2\n02 - Artist 2 - track2.mp3\n"
|
||||
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,7 +115,7 @@ 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.provider)
|
||||
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindAlbumArtwork:
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindMediaFileArtwork:
|
||||
|
||||
@@ -4,7 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -15,11 +19,11 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// TODO Fix tests
|
||||
var _ = XDescribe("Artwork", func() {
|
||||
var _ = Describe("Artwork", func() {
|
||||
var aw *artwork
|
||||
var ds model.DataStore
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
var folderRepo *fakeFolderRepo
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
||||
var arMultipleCovers model.Artist
|
||||
@@ -30,20 +34,21 @@ var _ = XDescribe("Artwork", func() {
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3"}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"}
|
||||
//alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/artist/an-album/front.png"}
|
||||
//alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"}
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
//Paths: []string{"tests/fixtures/artist/an-album"},
|
||||
//ImageFiles: "tests/fixtures/artist/an-album/cover.jpg" + consts.Zwsp +
|
||||
// "tests/fixtures/artist/an-album/front.png" + consts.Zwsp +
|
||||
// "tests/fixtures/artist/an-album/artist.png",
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
FolderIDs: []string{"f1"},
|
||||
AlbumArtistID: "777",
|
||||
}
|
||||
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
|
||||
@@ -65,6 +70,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = nil
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alEmbedNotFound,
|
||||
@@ -87,12 +93,17 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Context("External images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyExternal,
|
||||
alExternalNotFound,
|
||||
})
|
||||
})
|
||||
It("returns external cover", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
@@ -100,6 +111,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
|
||||
})
|
||||
It("returns ErrUnavailable if external file is not available", func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = aw.Reader(ctx)
|
||||
@@ -108,6 +120,10 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png", "artist.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
@@ -130,6 +146,10 @@ var _ = XDescribe("Artwork", func() {
|
||||
Describe("artistArtworkReader", func() {
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
arMultipleCovers,
|
||||
})
|
||||
@@ -143,7 +163,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -157,12 +177,16 @@ var _ = XDescribe("Artwork", func() {
|
||||
Describe("mediafileArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if mediafile is not in the DB", func() {
|
||||
_, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
||||
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alOnlyExternal,
|
||||
@@ -185,11 +209,17 @@ var _ = XDescribe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(io.ReadAll(r)).To(Equal([]byte("content from ffmpeg")))
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(data).ToNot(BeEmpty())
|
||||
Expect(path).To(Equal("tests/fixtures/test.ogg"))
|
||||
})
|
||||
It("returns album cover if cannot read embed artwork", func() {
|
||||
// Force fromTag to fail
|
||||
mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg"
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed())
|
||||
// Simulate ffmpeg error
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
@@ -207,6 +237,10 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Describe("resizedArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
@@ -241,12 +275,13 @@ var _ = XDescribe("Artwork", func() {
|
||||
DescribeTable("resize",
|
||||
func(format string, landscape bool, size int) {
|
||||
coverFileName := "cover." + format
|
||||
//dirName := createImage(format, landscape, size)
|
||||
dirName := createImage(format, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
//ImageFiles: filepath.Join(dirName, coverFileName),
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
@@ -270,24 +305,24 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
|
||||
//func createImage(format string, landscape bool, size int) string {
|
||||
// var img image.Image
|
||||
//
|
||||
// if landscape {
|
||||
// img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
// } else {
|
||||
// img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
// }
|
||||
//
|
||||
// tmpDir := GinkgoT().TempDir()
|
||||
// f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
// defer f.Close()
|
||||
// switch format {
|
||||
// case "png":
|
||||
// _ = png.Encode(f, img)
|
||||
// case "jpg":
|
||||
// _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
// }
|
||||
//
|
||||
// return tmpDir
|
||||
//}
|
||||
func createImage(format string, landscape bool, size int) string {
|
||||
var img image.Image
|
||||
|
||||
if landscape {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
} else {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
}
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
_ = png.Encode(f, img)
|
||||
case "jpg":
|
||||
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
// If the file cache is disabled, return a NOOP implementation
|
||||
if cache.Disabled(context.Background()) {
|
||||
log.Debug("Image cache disabled. Cache warmer will not run")
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
@@ -53,6 +59,9 @@ type cacheWarmer struct {
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
if a.cache.Disabled(context.Background()) {
|
||||
return
|
||||
}
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.buffer[artID] = struct{}{}
|
||||
@@ -74,6 +83,17 @@ func (a *cacheWarmer) run(ctx context.Context) {
|
||||
break
|
||||
}
|
||||
|
||||
if a.cache.Disabled(ctx) {
|
||||
a.mutex.Lock()
|
||||
pending := len(a.buffer)
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
if pending > 0 {
|
||||
log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If cache not available, keep waiting
|
||||
if !a.cache.Available(ctx) {
|
||||
if len(a.buffer) > 0 {
|
||||
|
||||
216
core/artwork/cache_warmer_test.go
Normal file
216
core/artwork/cache_warmer_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CacheWarmer", func() {
|
||||
var (
|
||||
fc *mockFileCache
|
||||
aw *mockArtwork
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
fc = &mockFileCache{}
|
||||
aw = &mockArtwork{}
|
||||
})
|
||||
|
||||
Context("initialization", func() {
|
||||
It("returns noop when cache is disabled", func() {
|
||||
fc.SetDisabled(true)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when ImageCacheSize is 0", func() {
|
||||
conf.Server.ImageCacheSize = "0"
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when EnableArtworkPrecache is false", func() {
|
||||
conf.Server.EnableArtworkPrecache = false
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns real implementation when properly configured", func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*cacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("buffer management", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("drops buffered items when cache becomes disabled", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-test"))
|
||||
fc.SetDisabled(true)
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("adds multiple items to buffer", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("error handling", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("continues processing after artwork retrieval error", func() {
|
||||
aw.err = errors.New("artwork error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("continues processing after cache error", func() {
|
||||
fc.err = errors.New("cache error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("background processing", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("processes items in batches", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
for i := 0; i < 5; i++ {
|
||||
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
||||
}
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("wakes up on new items", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
|
||||
// Add first batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
|
||||
// Add second batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockArtwork struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
if m.err != nil {
|
||||
return nil, time.Time{}, m.err
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||
}
|
||||
|
||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||
}
|
||||
|
||||
type mockFileCache struct {
|
||||
disabled atomic.Bool
|
||||
ready atomic.Bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Available(ctx context.Context) bool {
|
||||
return f.ready.Load() && !f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Disabled(ctx context.Context) bool {
|
||||
return f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetDisabled(v bool) {
|
||||
f.disabled.Store(v)
|
||||
f.ready.Store(true)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ type artistReader struct {
|
||||
imgFiles []string
|
||||
}
|
||||
|
||||
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("artistReader", func() {
|
||||
var _ = Describe("artistArtworkReader", func() {
|
||||
var _ = Describe("loadArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
|
||||
1
core/external/provider.go
vendored
1
core/external/provider.go
vendored
@@ -13,7 +13,6 @@ import (
|
||||
"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/mcp"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
||||
37
core/lyrics/lyrics.go
Normal file
37
core/lyrics/lyrics.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
var lyricsList model.LyricList
|
||||
var err error
|
||||
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
lyricsList, err = fromEmbedded(ctx, mf)
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
lyricsList, err = fromExternalFile(ctx, mf, pattern)
|
||||
default:
|
||||
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
return lyricsList, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package mcp_test
|
||||
package lyrics_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMCPAgent(t *testing.T) {
|
||||
func TestLyrics(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "MCP Agent Test Suite")
|
||||
RunSpecs(t, "Lyrics Suite")
|
||||
}
|
||||
124
core/lyrics/lyrics_test.go
Normal file
124
core/lyrics/lyrics_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package lyrics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
const badLyrics = "This is a set of lyrics\nThat is not good"
|
||||
unsynced, _ := model.ToLyrics("xxx", badLyrics)
|
||||
embeddedLyrics := model.LyricList{*unsynced}
|
||||
|
||||
syncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
unsyncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
lyricsJson, _ := json.Marshal(embeddedLyrics)
|
||||
|
||||
mf = model.MediaFile{
|
||||
Lyrics: string(lyricsJson),
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
}
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
},
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
|
||||
|
||||
Context("Errors", func() {
|
||||
var RegularUserContext = XContext
|
||||
var isRegularUser = os.Getuid() != 0
|
||||
if isRegularUser {
|
||||
RegularUserContext = Context
|
||||
}
|
||||
|
||||
RegularUserContext("run without root permissions", func() {
|
||||
var accessForbiddenFile string
|
||||
|
||||
BeforeEach(func() {
|
||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||
|
||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mf.Path = accessForbiddenFile
|
||||
|
||||
DeferCleanup(func() {
|
||||
Expect(f.Close()).To(Succeed())
|
||||
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
It("should fallback to embedded if an error happens when parsing file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3,embedded"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
})
|
||||
|
||||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
51
core/lyrics/sources.go
Normal file
51
core/lyrics/sources.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
if mf.Lyrics != "" {
|
||||
log.Trace(ctx, "embedded lyrics found in file", "title", mf.Title)
|
||||
return mf.StructuredLyrics()
|
||||
}
|
||||
|
||||
log.Trace(ctx, "no embedded lyrics for file", "path", mf.Title)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
|
||||
basePath := mf.AbsolutePath()
|
||||
ext := path.Ext(basePath)
|
||||
|
||||
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
|
||||
|
||||
contents, err := os.ReadFile(externalLyric)
|
||||
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lyrics, err := model.ToLyrics("xxx", string(contents))
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
|
||||
return nil, err
|
||||
} else if lyrics == nil {
|
||||
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
|
||||
|
||||
return model.LyricList{*lyrics}, nil
|
||||
}
|
||||
112
core/lyrics/sources_test.go
Normal file
112
core/lyrics/sources_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
Describe("fromEmbedded", func() {
|
||||
It("should return nothing for a media file with no lyrics", func() {
|
||||
mf := model.MediaFile{}
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return lyrics for a media file with well-formatted lyrics", func() {
|
||||
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
||||
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
||||
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
|
||||
expectedList := model.LyricList{*synced, *unsynced}
|
||||
lyricsJson, err := json.Marshal(expectedList)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mf := model.MediaFile{
|
||||
Lyrics: string(lyricsJson),
|
||||
}
|
||||
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(Equal(expectedList))
|
||||
})
|
||||
|
||||
It("should return an error if somehow the JSON is bad", func() {
|
||||
mf := model.MediaFile{Lyrics: "["}
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromExternalFile", func() {
|
||||
It("should return nil for lyrics that don't exist", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return synchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should return unsynchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -61,9 +60,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, name)
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
s = newBufferedScrobbler(ds, s, name)
|
||||
}
|
||||
s = newBufferedScrobbler(ds, s, name)
|
||||
p.scrobblers[name] = s
|
||||
}
|
||||
log.Debug("List of scrobblers enabled", "names", enabled)
|
||||
@@ -183,11 +180,7 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile,
|
||||
if !s.IsAuthorized(ctx, u.ID) {
|
||||
continue
|
||||
}
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
} else {
|
||||
log.Debug(ctx, "Sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
}
|
||||
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
err := s.Scrobble(ctx, u.ID, scrobble)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -27,9 +26,6 @@ var _ = Describe("PlayTracker", func() {
|
||||
var fake fakeScrobbler
|
||||
|
||||
BeforeEach(func() {
|
||||
// Remove buffering to simplify tests
|
||||
conf.Server.DevEnableBufferedScrobble = false
|
||||
|
||||
ctx = context.Background()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
@@ -42,6 +38,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
return nil
|
||||
})
|
||||
tracker = newPlayTracker(ds, events.GetBroker())
|
||||
tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests
|
||||
|
||||
track = model.MediaFile{
|
||||
ID: "123",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
@@ -93,7 +94,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
}
|
||||
s.ID = id
|
||||
if V(s.ExpiresAt).IsZero() {
|
||||
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
|
||||
s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
|
||||
}
|
||||
|
||||
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
|
||||
|
||||
36
db/migrations/20250522013904_share_user_id_on_delete.sql
Normal file
36
db/migrations/20250522013904_share_user_id_on_delete.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE share_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
expires_at datetime,
|
||||
last_visited_at datetime,
|
||||
resource_ids varchar not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
user_id varchar(255) not null
|
||||
constraint share_user_id_fk
|
||||
references user
|
||||
on update cascade on delete cascade,
|
||||
downloadable bool not null default false,
|
||||
description varchar not null default '',
|
||||
resource_type varchar not null default '',
|
||||
contents varchar not null default '',
|
||||
format varchar not null default '',
|
||||
max_bit_rate integer not null default 0,
|
||||
visit_count integer not null default 0
|
||||
);
|
||||
|
||||
|
||||
INSERT INTO share_tmp(
|
||||
id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
|
||||
) SELECT id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
|
||||
FROM share;
|
||||
|
||||
DROP TABLE share;
|
||||
|
||||
ALTER TABLE share_tmp RENAME To share;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
45
go.mod
45
go.mod
@@ -35,18 +35,17 @@ require (
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/kardianos/service v1.2.2
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.27
|
||||
github.com/metoro-io/mcp-golang v0.11.0
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.23.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.2
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/pressly/goose/v3 v3.24.3
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
@@ -54,25 +53,22 @@ require (
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tetratelabs/wazero v1.9.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.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/exp v0.0.0-20250506013437-ce4c2cf36ca6
|
||||
golang.org/x/image v0.27.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/sync v0.14.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/text v0.25.0
|
||||
golang.org/x/time v0.11.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.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
|
||||
@@ -84,53 +80,44 @@ require (
|
||||
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-20250403155104-27863c87afa6 // indirect
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/invopop/jsonschema v0.12.0 // 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
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ogier/pflag v0.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.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.16.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // 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.14.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/cast v1.8.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // 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.37.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
|
||||
107
go.sum
107
go.sum
@@ -8,16 +8,12 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
||||
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/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
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=
|
||||
@@ -70,8 +66,8 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/
|
||||
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.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
|
||||
@@ -89,8 +85,8 @@ 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-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -108,11 +104,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
|
||||
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
|
||||
@@ -137,28 +130,24 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4/qc=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
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.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
|
||||
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/metoro-io/mcp-golang v0.11.0 h1:1k+VSE9QaeMTLn0gJ3FgE/DcjsCBsLFnz5eSFbgXUiI=
|
||||
github.com/metoro-io/mcp-golang v0.11.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
@@ -178,8 +167,6 @@ github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU
|
||||
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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=
|
||||
@@ -187,16 +174,16 @@ github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
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/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
|
||||
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
||||
@@ -226,8 +213,8 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
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/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
|
||||
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
@@ -247,22 +234,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
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=
|
||||
@@ -283,13 +256,13 @@ 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.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/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -310,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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -319,8 +292,8 @@ 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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -339,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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -361,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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -373,8 +346,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
@@ -390,11 +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/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.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=
|
||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -32,6 +32,8 @@ type Artist struct {
|
||||
SimilarArtists Artists `structs:"similar_artists" json:"-"`
|
||||
ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt,omitempty"`
|
||||
|
||||
Missing bool `structs:"missing" json:"missing"`
|
||||
|
||||
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
||||
}
|
||||
@@ -76,7 +78,7 @@ type ArtistRepository interface {
|
||||
UpdateExternalInfo(a *Artist) error
|
||||
Get(id string) (*Artist, error)
|
||||
GetAll(options ...QueryOptions) (Artists, error)
|
||||
GetIndex(roles ...Role) (ArtistIndexes, error)
|
||||
GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
RefreshPlayCounts() (int64, error)
|
||||
|
||||
@@ -4,6 +4,7 @@ package criteria
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
@@ -40,6 +41,9 @@ func (c Criteria) OrderBy() string {
|
||||
} else {
|
||||
mapped = f.field
|
||||
}
|
||||
if f.numeric {
|
||||
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
|
||||
}
|
||||
}
|
||||
if c.Order != "" {
|
||||
if strings.EqualFold(c.Order, "asc") || strings.EqualFold(c.Order, "desc") {
|
||||
|
||||
@@ -109,6 +109,15 @@ var _ = Describe("Criteria", func() {
|
||||
)
|
||||
})
|
||||
|
||||
It("casts numeric tags when sorting", func() {
|
||||
AddTagNames([]string{"rate"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
goObj.Sort = "rate"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by random", func() {
|
||||
newObj := goObj
|
||||
newObj.Sort = "random"
|
||||
|
||||
@@ -54,11 +54,12 @@ var fieldMap = map[string]*mappedField{
|
||||
}
|
||||
|
||||
type mappedField struct {
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
isTag bool // true if the field is a tag imported from the file metadata
|
||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
isTag bool // true if the field is a tag imported from the file metadata
|
||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||
numeric bool // true if the field/tag should be treated as numeric
|
||||
}
|
||||
|
||||
func mapFields(expr map[string]any) map[string]any {
|
||||
@@ -145,6 +146,12 @@ type tagCond struct {
|
||||
|
||||
func (e tagCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
|
||||
// Check if this tag is marked as numeric in the fieldMap
|
||||
if fm, ok := fieldMap[e.tag]; ok && fm.numeric {
|
||||
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
||||
}
|
||||
|
||||
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
|
||||
e.tag, cond)
|
||||
if e.not {
|
||||
@@ -205,3 +212,16 @@ func AddTagNames(tagNames []string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddNumericTags marks the given tag names as numeric so they can be cast
|
||||
// when used in comparisons or sorting.
|
||||
func AddNumericTags(tagNames []string) {
|
||||
for _, name := range tagNames {
|
||||
name := strings.ToLower(name)
|
||||
if fm, ok := fieldMap[name]; ok {
|
||||
fm.numeric = true
|
||||
} else {
|
||||
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
var _ = BeforeSuite(func() {
|
||||
AddRoles([]string{"artist", "composer"})
|
||||
AddTagNames([]string{"genre"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
})
|
||||
|
||||
var _ = Describe("Operators", func() {
|
||||
@@ -68,6 +69,15 @@ var _ = Describe("Operators", func() {
|
||||
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
|
||||
)
|
||||
|
||||
// TODO Validate operators that are not valid for each field type.
|
||||
XDescribeTable("ToSQL - Invalid Operators",
|
||||
func(op Expression, expectedError string) {
|
||||
_, _, err := op.ToSql()
|
||||
gomega.Expect(err).To(gomega.MatchError(expectedError))
|
||||
},
|
||||
Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
|
||||
)
|
||||
|
||||
Describe("Custom Tags", func() {
|
||||
It("generates valid SQL", func() {
|
||||
AddTagNames([]string{"mood"})
|
||||
@@ -77,6 +87,14 @@ var _ = Describe("Operators", func() {
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
|
||||
})
|
||||
It("casts numeric comparisons", func() {
|
||||
AddNumericTags([]string{"rate"})
|
||||
op := Lt{"rate": 6}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements(6))
|
||||
})
|
||||
It("skips unknown tag names", func() {
|
||||
op := EndsWith{"unknown": "value"}
|
||||
sql, args, _ := op.ToSql()
|
||||
|
||||
@@ -32,7 +32,7 @@ var (
|
||||
// Should either be at the beginning of file, or beginning of line
|
||||
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
|
||||
timeRegex = regexp.MustCompile(timeRegexString)
|
||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset):([^]]+)]`)
|
||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
|
||||
)
|
||||
|
||||
func (l Lyrics) IsEmpty() bool {
|
||||
@@ -72,6 +72,8 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
||||
switch idTag[1] {
|
||||
case "ar":
|
||||
artist = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
case "lang":
|
||||
language = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
case "offset":
|
||||
{
|
||||
off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64)
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
var _ = Describe("ToLyrics", func() {
|
||||
It("should parse tags with spaces", func() {
|
||||
num := int64(1551)
|
||||
lyrics, err := ToLyrics("xxx", "[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
|
||||
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Lang).To(Equal("eng"))
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.DisplayArtist).To(Equal("An artist"))
|
||||
Expect(lyrics.DisplayTitle).To(Equal("A title"))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
@@ -330,6 +331,23 @@ func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int
|
||||
return currentPath, currentDisc
|
||||
}
|
||||
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
||||
func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string {
|
||||
buf := strings.Builder{}
|
||||
buf.WriteString("#EXTM3U\n")
|
||||
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", title))
|
||||
for _, t := range mfs {
|
||||
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
|
||||
if absolutePaths {
|
||||
buf.WriteString(t.AbsolutePath() + "\n")
|
||||
} else {
|
||||
buf.WriteString(t.Path + "\n")
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
type MediaFileCursor iter.Seq2[MediaFile, error]
|
||||
|
||||
type MediaFileRepository interface {
|
||||
@@ -342,6 +360,7 @@ type MediaFileRepository interface {
|
||||
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
|
||||
Delete(id string) error
|
||||
DeleteMissing(ids []string) error
|
||||
DeleteAllMissing() (int64, error)
|
||||
FindByPaths(paths []string) (MediaFiles, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
|
||||
@@ -402,6 +402,72 @@ var _ = Describe("MediaFiles", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ToM3U8", func() {
|
||||
It("returns header only for empty MediaFiles", func() {
|
||||
mfs = MediaFiles{}
|
||||
result := mfs.ToM3U8("My Playlist", false)
|
||||
Expect(result).To(Equal("#EXTM3U\n#PLAYLIST:My Playlist\n"))
|
||||
})
|
||||
|
||||
DescribeTable("duration formatting",
|
||||
func(duration float32, expected string) {
|
||||
mfs = MediaFiles{{Title: "Song", Artist: "Artist", Duration: duration, Path: "song.mp3"}}
|
||||
result := mfs.ToM3U8("Test", false)
|
||||
Expect(result).To(ContainSubstring(expected))
|
||||
},
|
||||
Entry("zero duration", float32(0.0), "#EXTINF:0,"),
|
||||
Entry("whole number", float32(120.0), "#EXTINF:120,"),
|
||||
Entry("rounds 0.5 down", float32(180.5), "#EXTINF:180,"),
|
||||
Entry("rounds 0.6 up", float32(240.6), "#EXTINF:241,"),
|
||||
)
|
||||
|
||||
Context("multiple tracks", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Title: "Song One", Artist: "Artist A", Duration: 120, Path: "a/song1.mp3", LibraryPath: "/music"},
|
||||
{Title: "Song Two", Artist: "Artist B", Duration: 241, Path: "b/song2.mp3", LibraryPath: "/music"},
|
||||
{Title: "Song with \"quotes\" & ampersands", Artist: "Artist with Ümläuts", Duration: 90, Path: "special/file.mp3", LibraryPath: "/música"},
|
||||
}
|
||||
})
|
||||
|
||||
DescribeTable("generates correct output",
|
||||
func(absolutePaths bool, expectedContent string) {
|
||||
result := mfs.ToM3U8("Multi Track", absolutePaths)
|
||||
Expect(result).To(Equal(expectedContent))
|
||||
},
|
||||
Entry("relative paths",
|
||||
false,
|
||||
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
|
||||
),
|
||||
Entry("absolute paths",
|
||||
true,
|
||||
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\n/music/a/song1.mp3\n#EXTINF:241,Artist B - Song Two\n/music/b/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\n/música/special/file.mp3\n",
|
||||
),
|
||||
Entry("special characters",
|
||||
false,
|
||||
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
Context("path variations", func() {
|
||||
It("handles different path structures", func() {
|
||||
mfs = MediaFiles{
|
||||
{Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"},
|
||||
{Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"},
|
||||
}
|
||||
|
||||
relativeResult := mfs.ToM3U8("Test", false)
|
||||
Expect(relativeResult).To(ContainSubstring("song.mp3\n"))
|
||||
Expect(relativeResult).To(ContainSubstring("deep/nested/song.mp3\n"))
|
||||
|
||||
absoluteResult := mfs.ToM3U8("Test", true)
|
||||
Expect(absoluteResult).To(ContainSubstring("/lib/song.mp3\n"))
|
||||
Expect(absoluteResult).To(ContainSubstring("/lib/deep/nested/song.mp3\n"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("MediaFile", func() {
|
||||
|
||||
@@ -564,6 +564,58 @@ var _ = Describe("Participants", func() {
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
When("MUSICBRAINZ_PERFORMERID tag is set", func() {
|
||||
matchPerformer := func(name, orderName, subRole, mbid string) types.GomegaMatcher {
|
||||
return MatchFields(IgnoreExtras, Fields{
|
||||
"Artist": MatchFields(IgnoreExtras, Fields{
|
||||
"Name": Equal(name),
|
||||
"OrderArtistName": Equal(orderName),
|
||||
"MbzArtistID": Equal(mbid),
|
||||
}),
|
||||
"SubRole": Equal(subRole),
|
||||
})
|
||||
}
|
||||
|
||||
It("should map MBIDs to the correct performer", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
|
||||
"PERFORMER:BASS": {"Nathan East"},
|
||||
"MUSICBRAINZ_PERFORMERID:GUITAR": {"mbid1", "mbid2"},
|
||||
"MUSICBRAINZ_PERFORMERID:BASS": {"mbid3"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3)))
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Eric Clapton", "eric clapton", "Guitar", "mbid1"),
|
||||
matchPerformer("B.B. King", "b.b. king", "Guitar", "mbid2"),
|
||||
matchPerformer("Nathan East", "nathan east", "Bass", "mbid3"),
|
||||
))
|
||||
})
|
||||
|
||||
It("should handle mismatched performer names and MBIDs for sub-roles", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:VOCALS": {"Singer A", "Singer B", "Singer C"},
|
||||
"MUSICBRAINZ_PERFORMERID:VOCALS": {"mbid_vocals_a", "mbid_vocals_b"}, // Fewer MBIDs
|
||||
"PERFORMER:DRUMS": {"Drummer X"},
|
||||
"MUSICBRAINZ_PERFORMERID:DRUMS": {"mbid_drums_x", "mbid_drums_y"}, // More MBIDs
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4))) // 3 vocalists + 1 drummer
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Singer A", "singer a", "Vocals", "mbid_vocals_a"),
|
||||
matchPerformer("Singer B", "singer b", "Vocals", "mbid_vocals_b"),
|
||||
matchPerformer("Singer C", "singer c", "Vocals", ""),
|
||||
matchPerformer("Drummer X", "drummer x", "Drums", "mbid_drums_x"),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Other tags", func() {
|
||||
@@ -592,7 +644,6 @@ var _ = Describe("Participants", func() {
|
||||
Entry("REMIXER", model.RoleRemixer, "REMIXER"),
|
||||
Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"),
|
||||
Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"),
|
||||
// TODO PERFORMER
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
@@ -53,17 +51,9 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) {
|
||||
pls.Tracks = newTracks
|
||||
}
|
||||
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format
|
||||
func (pls *Playlist) ToM3U8() string {
|
||||
buf := strings.Builder{}
|
||||
buf.WriteString("#EXTM3U\n")
|
||||
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", pls.Name))
|
||||
for _, t := range pls.Tracks {
|
||||
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
|
||||
buf.WriteString(t.AbsolutePath() + "\n")
|
||||
}
|
||||
return buf.String()
|
||||
return pls.MediaFiles().ToM3U8(pls.Name, true)
|
||||
}
|
||||
|
||||
func (pls *Playlist) AddTracks(mediaFileIds []string) {
|
||||
|
||||
@@ -13,13 +13,17 @@ var _ = Describe("Playlist", func() {
|
||||
pls = model.Playlist{Name: "Mellow sunset"}
|
||||
pls.Tracks = model.PlaylistTracks{
|
||||
{MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About",
|
||||
Duration: 377.84, Path: "/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
|
||||
Duration: 377.84,
|
||||
LibraryPath: "/music/library", Path: "Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
|
||||
{MediaFile: model.MediaFile{Artist: "A Tribe Called Quest", Title: "Description of a Fool (Groove Armada's Acoustic mix)",
|
||||
Duration: 374.49, Path: "/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
|
||||
Duration: 374.49,
|
||||
LibraryPath: "/music/library", Path: "Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
|
||||
{MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side",
|
||||
Duration: 253.1, Path: "/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
|
||||
Duration: 253.1,
|
||||
LibraryPath: "/music/library", Path: "Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
|
||||
{MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home",
|
||||
Duration: 163.89, Path: "/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
|
||||
Duration: 163.89,
|
||||
LibraryPath: "/music/library", Path: "Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
|
||||
}
|
||||
})
|
||||
It("generates the correct M3U format", func() {
|
||||
@@ -2,7 +2,6 @@ package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -50,17 +49,9 @@ func (s Share) CoverArtID() ArtworkID {
|
||||
|
||||
type Shares []Share
|
||||
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
||||
// ToM3U8 exports the share to the Extended M3U8 format.
|
||||
func (s Share) ToM3U8() string {
|
||||
buf := strings.Builder{}
|
||||
buf.WriteString("#EXTM3U\n")
|
||||
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", cmp.Or(s.Description, s.ID)))
|
||||
for _, t := range s.Tracks {
|
||||
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
|
||||
buf.WriteString(t.Path + "\n")
|
||||
}
|
||||
return buf.String()
|
||||
return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false)
|
||||
}
|
||||
|
||||
type ShareRepository interface {
|
||||
|
||||
@@ -191,6 +191,7 @@ const (
|
||||
TagReleaseCountry TagName = "releasecountry"
|
||||
TagMedia TagName = "media"
|
||||
TagCatalogNumber TagName = "catalognumber"
|
||||
TagISRC TagName = "isrc"
|
||||
TagBPM TagName = "bpm"
|
||||
TagExplicitStatus TagName = "explicitstatus"
|
||||
|
||||
|
||||
@@ -162,6 +162,17 @@ func tagNames() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func numericTagNames() []string {
|
||||
mappings := TagMappings()
|
||||
names := make([]string, 0)
|
||||
for k, cfg := range mappings {
|
||||
if cfg.Type == TagTypeInteger || cfg.Type == TagTypeFloat {
|
||||
names = append(names, string(k))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func loadTagMappings() {
|
||||
mappingsFile, err := resources.FS().Open("mappings.yaml")
|
||||
if err != nil {
|
||||
@@ -228,5 +239,6 @@ func init() {
|
||||
// used in smart playlists
|
||||
criteria.AddRoles(slices.Collect(maps.Keys(AllRoles)))
|
||||
criteria.AddTagNames(tagNames())
|
||||
criteria.AddNumericTags(numericTagNames())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error)
|
||||
// RefreshPlayCounts updates the play count and last play date annotations for all albums, based
|
||||
// on the media files associated with them.
|
||||
func (r *albumRepository) RefreshPlayCounts() (int64, error) {
|
||||
query := rawSQL(`
|
||||
query := Expr(`
|
||||
with play_counts as (
|
||||
select user_id, album_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
|
||||
from media_file
|
||||
|
||||
@@ -116,6 +116,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"name": fullTextFilter(r.tableName),
|
||||
"starred": booleanFilter,
|
||||
"role": roleFilter,
|
||||
"missing": booleanFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
@@ -128,7 +129,12 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
}
|
||||
|
||||
func roleFilter(_ string, role any) Sqlizer {
|
||||
return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
|
||||
if role, ok := role.(string); ok {
|
||||
if _, ok := model.AllRoles[role]; ok {
|
||||
return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
|
||||
}
|
||||
}
|
||||
return Eq{"1": 2}
|
||||
}
|
||||
|
||||
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
|
||||
@@ -202,7 +208,7 @@ func (r *artistRepository) getIndexKey(a model.Artist) string {
|
||||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
options := model.QueryOptions{Sort: "name"}
|
||||
if len(roles) > 0 {
|
||||
roleFilters := slice.Map(roles, func(r model.Role) Sqlizer {
|
||||
@@ -210,6 +216,13 @@ func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, e
|
||||
})
|
||||
options.Filters = And(roleFilters)
|
||||
}
|
||||
if !includeMissing {
|
||||
if options.Filters == nil {
|
||||
options.Filters = Eq{"artist.missing": false}
|
||||
} else {
|
||||
options.Filters = And{options.Filters, Eq{"artist.missing": false}}
|
||||
}
|
||||
}
|
||||
artists, err := r.GetAll(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -236,10 +249,29 @@ func (r *artistRepository) purgeEmpty() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// markMissing marks artists as missing if all their albums are missing.
|
||||
func (r *artistRepository) markMissing() error {
|
||||
q := Expr(`
|
||||
with artists_with_non_missing_albums as (
|
||||
select distinct aa.artist_id
|
||||
from album_artists aa
|
||||
join album a on aa.album_id = a.id
|
||||
where a.missing = false
|
||||
)
|
||||
update artist
|
||||
set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums));
|
||||
`)
|
||||
_, err := r.executeSQL(q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking missing artists: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshPlayCounts updates the play count and last play date annotations for all artists, based
|
||||
// on the media files associated with them.
|
||||
func (r *artistRepository) RefreshPlayCounts() (int64, error) {
|
||||
query := rawSQL(`
|
||||
query := Expr(`
|
||||
with play_counts as (
|
||||
select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
|
||||
from media_file
|
||||
@@ -259,76 +291,123 @@ on conflict (user_id, item_id, item_type) do update
|
||||
return r.executeSQL(query)
|
||||
}
|
||||
|
||||
// RefreshStats updates the stats field for all artists, based on the media files associated with them.
|
||||
// BFR Maybe filter by "touched" artists?
|
||||
// RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time.
|
||||
// It processes artists in batches to handle potentially large updates.
|
||||
func (r *artistRepository) RefreshStats() (int64, error) {
|
||||
// First get all counters, one query groups by artist/role, and another with totals per artist.
|
||||
// Union both queries and group by artist to get a single row of counters per artist/role.
|
||||
// Then format the counters in a JSON object, one key for each role.
|
||||
// Finally update the artist table with the new counters
|
||||
// In all queries, atom is the artist ID and path is the role (or "total" for the totals)
|
||||
query := rawSQL(`
|
||||
-- CTE to get counters for each artist, grouped by role
|
||||
with artist_role_counters as (
|
||||
-- Get counters for each artist, grouped by role
|
||||
-- (remove the index from the role: composer[0] => composer
|
||||
select atom as artist_id,
|
||||
substr(
|
||||
replace(jt.path, '$.', ''),
|
||||
1,
|
||||
case when instr(replace(jt.path, '$.', ''), '[') > 0
|
||||
then instr(replace(jt.path, '$.', ''), '[') - 1
|
||||
else length(replace(jt.path, '$.', ''))
|
||||
end
|
||||
) as role,
|
||||
count(distinct album_id) as album_count,
|
||||
count(mf.id) as count,
|
||||
sum(size) as size
|
||||
from media_file mf
|
||||
left join json_tree(participants) jt
|
||||
where atom is not null and key = 'id'
|
||||
group by atom, role
|
||||
),
|
||||
touchedArtistsQuerySQL := `
|
||||
SELECT DISTINCT mfa.artist_id
|
||||
FROM media_file_artists mfa
|
||||
JOIN media_file mf ON mfa.media_file_id = mf.id
|
||||
WHERE mf.updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1)
|
||||
`
|
||||
|
||||
-- CTE to get the totals for each artist
|
||||
artist_total_counters as (
|
||||
select mfa.artist_id,
|
||||
'total' as role,
|
||||
count(distinct mf.album_id) as album_count,
|
||||
count(distinct mf.id) as count,
|
||||
sum(mf.size) as size
|
||||
from (select artist_id, media_file_id
|
||||
from main.media_file_artists) as mfa
|
||||
join main.media_file mf on mfa.media_file_id = mf.id
|
||||
group by mfa.artist_id
|
||||
),
|
||||
var allTouchedArtistIDs []string
|
||||
if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil {
|
||||
return 0, fmt.Errorf("fetching touched artist IDs: %w", err)
|
||||
}
|
||||
|
||||
-- CTE to combine role and total counters
|
||||
combined_counters as (
|
||||
select artist_id, role, album_count, count, size
|
||||
from artist_role_counters
|
||||
union
|
||||
select artist_id, role, album_count, count, size
|
||||
from artist_total_counters
|
||||
),
|
||||
if len(allTouchedArtistIDs) == 0 {
|
||||
log.Debug(r.ctx, "RefreshStats: No artists to update.")
|
||||
return 0, nil
|
||||
}
|
||||
log.Debug(r.ctx, "RefreshStats: Found artists to update.", "count", len(allTouchedArtistIDs))
|
||||
|
||||
-- CTE to format the counters in a JSON object
|
||||
artist_counters as (
|
||||
select artist_id as id,
|
||||
json_group_object(
|
||||
replace(role, '"', ''),
|
||||
json_object('a', album_count, 'm', count, 's', size)
|
||||
) as counters
|
||||
from combined_counters
|
||||
group by artist_id
|
||||
)
|
||||
// Template for the batch update with placeholder markers that we'll replace
|
||||
batchUpdateStatsSQL := `
|
||||
WITH artist_role_counters AS (
|
||||
SELECT jt.atom AS artist_id,
|
||||
substr(
|
||||
replace(jt.path, '$.', ''),
|
||||
1,
|
||||
CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0
|
||||
THEN instr(replace(jt.path, '$.', ''), '[') - 1
|
||||
ELSE length(replace(jt.path, '$.', ''))
|
||||
END
|
||||
) AS role,
|
||||
count(DISTINCT mf.album_id) AS album_count,
|
||||
count(mf.id) AS count,
|
||||
sum(mf.size) AS size
|
||||
FROM media_file mf
|
||||
JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
|
||||
WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
|
||||
GROUP BY jt.atom, role
|
||||
),
|
||||
artist_total_counters AS (
|
||||
SELECT mfa.artist_id,
|
||||
'total' AS role,
|
||||
count(DISTINCT mf.album_id) AS album_count,
|
||||
count(DISTINCT mf.id) AS count,
|
||||
sum(mf.size) AS size
|
||||
FROM media_file_artists mfa
|
||||
JOIN media_file mf ON mfa.media_file_id = mf.id
|
||||
WHERE mfa.artist_id IN (TOTAL_IDS_PLACEHOLDER) -- Will replace with actual placeholders
|
||||
GROUP BY mfa.artist_id
|
||||
),
|
||||
combined_counters AS (
|
||||
SELECT artist_id, role, album_count, count, size FROM artist_role_counters
|
||||
UNION
|
||||
SELECT artist_id, role, album_count, count, size FROM artist_total_counters
|
||||
),
|
||||
artist_counters AS (
|
||||
SELECT artist_id AS id,
|
||||
json_group_object(
|
||||
replace(role, '"', ''),
|
||||
json_object('a', album_count, 'm', count, 's', size)
|
||||
) AS counters
|
||||
FROM combined_counters
|
||||
GROUP BY artist_id
|
||||
)
|
||||
UPDATE artist
|
||||
SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'),
|
||||
updated_at = datetime(current_timestamp, 'localtime')
|
||||
WHERE artist.id IN (UPDATE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders
|
||||
|
||||
-- Update the artist table with the new counters
|
||||
update artist
|
||||
set stats = coalesce((select counters from artist_counters where artist_counters.id = artist.id), '{}'),
|
||||
updated_at = datetime(current_timestamp, 'localtime')
|
||||
where id <> ''; -- always true, to avoid warnings`)
|
||||
return r.executeSQL(query)
|
||||
var totalRowsAffected int64 = 0
|
||||
const batchSize = 1000
|
||||
|
||||
batchCounter := 0
|
||||
for artistIDBatch := range slice.CollectChunks(slices.Values(allTouchedArtistIDs), batchSize) {
|
||||
batchCounter++
|
||||
log.Trace(r.ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch))
|
||||
|
||||
// Create placeholders for each ID in the IN clauses
|
||||
placeholders := make([]string, len(artistIDBatch))
|
||||
for i := range artistIDBatch {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
// Don't add extra parentheses, the IN clause already expects them in SQL syntax
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
|
||||
// Replace the placeholder markers with actual SQL placeholders
|
||||
batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 1)
|
||||
batchSQL = strings.Replace(batchSQL, "TOTAL_IDS_PLACEHOLDER", inClause, 1)
|
||||
batchSQL = strings.Replace(batchSQL, "UPDATE_IDS_PLACEHOLDER", inClause, 1)
|
||||
|
||||
// Create a single parameter array with all IDs (repeated 3 times for each IN clause)
|
||||
// We need to repeat each ID 3 times (once for each IN clause)
|
||||
var args []interface{}
|
||||
for _, id := range artistIDBatch {
|
||||
args = append(args, id) // For ROLE_IDS_PLACEHOLDER
|
||||
}
|
||||
for _, id := range artistIDBatch {
|
||||
args = append(args, id) // For TOTAL_IDS_PLACEHOLDER
|
||||
}
|
||||
for _, id := range artistIDBatch {
|
||||
args = append(args, id) // For UPDATE_IDS_PLACEHOLDER
|
||||
}
|
||||
|
||||
// Now use Expr with the expanded SQL and all parameters
|
||||
sqlizer := Expr(batchSQL, args...)
|
||||
|
||||
rowsAffected, err := r.executeSQL(sqlizer)
|
||||
if err != nil {
|
||||
return totalRowsAffected, fmt.Errorf("executing batch update for artist stats (batch %d): %w", batchCounter, err)
|
||||
}
|
||||
totalRowsAffected += rowsAffected
|
||||
}
|
||||
|
||||
log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected)
|
||||
return totalRowsAffected, nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -94,7 +95,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("F"))
|
||||
@@ -112,7 +113,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
|
||||
// BFR Empty SortArtistName is not saved in the DB anymore
|
||||
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -134,7 +135,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -151,7 +152,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
})
|
||||
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -233,5 +234,113 @@ var _ = Describe("ArtistRepository", func() {
|
||||
Expect(m).ToNot(HaveKey("mbz_artist_id"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Missing artist visibility", func() {
|
||||
var raw *artistRepository
|
||||
var missing model.Artist
|
||||
|
||||
insertMissing := func() {
|
||||
missing = model.Artist{ID: "m1", Name: "Missing", OrderArtistName: "missing"}
|
||||
Expect(repo.Put(&missing)).To(Succeed())
|
||||
raw = repo.(*artistRepository)
|
||||
_, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missing.ID}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
removeMissing := func() {
|
||||
if raw != nil {
|
||||
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missing.ID}))
|
||||
}
|
||||
}
|
||||
|
||||
Context("regular user", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u1"})
|
||||
repo = NewArtistRepository(ctx, GetDBXBuilder())
|
||||
insertMissing()
|
||||
})
|
||||
|
||||
AfterEach(func() { removeMissing() })
|
||||
|
||||
It("does not return missing artist in GetAll", func() {
|
||||
artists, err := repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.missing": false}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("does not return missing artist in Search", func() {
|
||||
res, err := repo.Search("missing", 0, 10, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not return missing artist in GetIndex", func() {
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Only 2 artists should be present
|
||||
total := 0
|
||||
for _, ix := range idx {
|
||||
total += len(ix.Artists)
|
||||
}
|
||||
Expect(total).To(Equal(2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("admin user", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true})
|
||||
repo = NewArtistRepository(ctx, GetDBXBuilder())
|
||||
insertMissing()
|
||||
})
|
||||
|
||||
AfterEach(func() { removeMissing() })
|
||||
|
||||
It("returns missing artist in GetAll", func() {
|
||||
artists, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("returns missing artist in Search", func() {
|
||||
res, err := repo.Search("missing", 0, 10, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns missing artist in GetIndex when included", func() {
|
||||
idx, err := repo.GetIndex(true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
total := 0
|
||||
for _, ix := range idx {
|
||||
total += len(ix.Artists)
|
||||
}
|
||||
Expect(total).To(Equal(3))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("roleFilter", func() {
|
||||
It("filters out roles not present in the participants model", func() {
|
||||
Expect(roleFilter("", "artist")).To(Equal(squirrel.NotEq{"stats ->> '$.artist'": nil}))
|
||||
Expect(roleFilter("", "albumartist")).To(Equal(squirrel.NotEq{"stats ->> '$.albumartist'": nil}))
|
||||
Expect(roleFilter("", "composer")).To(Equal(squirrel.NotEq{"stats ->> '$.composer'": nil}))
|
||||
Expect(roleFilter("", "conductor")).To(Equal(squirrel.NotEq{"stats ->> '$.conductor'": nil}))
|
||||
Expect(roleFilter("", "lyricist")).To(Equal(squirrel.NotEq{"stats ->> '$.lyricist'": nil}))
|
||||
Expect(roleFilter("", "arranger")).To(Equal(squirrel.NotEq{"stats ->> '$.arranger'": nil}))
|
||||
Expect(roleFilter("", "producer")).To(Equal(squirrel.NotEq{"stats ->> '$.producer'": nil}))
|
||||
Expect(roleFilter("", "director")).To(Equal(squirrel.NotEq{"stats ->> '$.director'": nil}))
|
||||
Expect(roleFilter("", "engineer")).To(Equal(squirrel.NotEq{"stats ->> '$.engineer'": nil}))
|
||||
Expect(roleFilter("", "mixer")).To(Equal(squirrel.NotEq{"stats ->> '$.mixer'": nil}))
|
||||
Expect(roleFilter("", "remixer")).To(Equal(squirrel.NotEq{"stats ->> '$.remixer'": nil}))
|
||||
Expect(roleFilter("", "djmixer")).To(Equal(squirrel.NotEq{"stats ->> '$.djmixer'": nil}))
|
||||
Expect(roleFilter("", "performer")).To(Equal(squirrel.NotEq{"stats ->> '$.performer'": nil}))
|
||||
|
||||
Expect(roleFilter("", "wizard")).To(Equal(squirrel.Eq{"1": 2}))
|
||||
Expect(roleFilter("", "songanddanceman")).To(Equal(squirrel.Eq{"1": 2}))
|
||||
Expect(roleFilter("", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--")).To(Equal(squirrel.Eq{"1": 2}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,14 +57,6 @@ func toCamelCase(str string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// rawSQL is a string that will be used as is in the SQL query executor
|
||||
// It does not support arguments
|
||||
type rawSQL string
|
||||
|
||||
func (r rawSQL) ToSql() (string, []interface{}, error) {
|
||||
return string(r), nil, nil
|
||||
}
|
||||
|
||||
func Exists(subTable string, cond squirrel.Sqlizer) existsCond {
|
||||
return existsCond{subTable: subTable, cond: cond, not: false}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func (r *libraryRepository) ScanEnd(id int) error {
|
||||
return err
|
||||
}
|
||||
// https://www.sqlite.org/pragma.html#pragma_optimize
|
||||
_, err = r.executeSQL(rawSQL("PRAGMA optimize=0x10012;"))
|
||||
_, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;"))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -87,11 +87,12 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
|
||||
|
||||
var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
filters := map[string]filterFunc{
|
||||
"id": idFilter("media_file"),
|
||||
"title": fullTextFilter("media_file"),
|
||||
"starred": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"id": idFilter("media_file"),
|
||||
"title": fullTextFilter("media_file"),
|
||||
"starred": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"artists_id": artistFilter,
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.TagMappings() {
|
||||
@@ -191,6 +192,15 @@ func (r *mediaFileRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) DeleteAllMissing() (int64, error) {
|
||||
user := loggedUser(r.ctx)
|
||||
if !user.IsAdmin {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
del := Delete(r.tableName).Where(Eq{"missing": true})
|
||||
return r.executeSQL(del)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) DeleteMissing(ids []string) error {
|
||||
user := loggedUser(r.ctx)
|
||||
if !user.IsAdmin {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
@@ -44,14 +45,39 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("delete tracks by id", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed())
|
||||
|
||||
Expect(mr.Delete(newID)).To(BeNil())
|
||||
Expect(mr.Delete(newID)).To(Succeed())
|
||||
|
||||
_, err := mr.Get(newID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("deletes all missing files", func() {
|
||||
new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
|
||||
new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
|
||||
Expect(mr.Put(&new1)).To(Succeed())
|
||||
Expect(mr.Put(&new2)).To(Succeed())
|
||||
Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed())
|
||||
|
||||
adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true})
|
||||
adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder())
|
||||
|
||||
// Ensure the files are marked as missing and we have 2 of them
|
||||
count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err = adminRepo.DeleteAllMissing()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
|
||||
_, err = mr.Get(new1.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = mr.Get(new2.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
Context("Annotations", func() {
|
||||
It("increments play count when the tracks does not have annotations", func() {
|
||||
id := "incplay.firsttime"
|
||||
|
||||
@@ -170,6 +170,7 @@ func (s *SQLStore) GC(ctx context.Context) error {
|
||||
err := chain.RunSequentially(
|
||||
trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }),
|
||||
trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }),
|
||||
trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }),
|
||||
trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }),
|
||||
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
|
||||
|
||||
@@ -65,6 +65,11 @@ func loggedUser(ctx context.Context) *model.User {
|
||||
}
|
||||
}
|
||||
|
||||
func isAdmin(ctx context.Context) bool {
|
||||
user := loggedUser(ctx)
|
||||
return user.IsAdmin
|
||||
}
|
||||
|
||||
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
||||
if r.tableName == "" {
|
||||
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
||||
|
||||
@@ -60,7 +60,7 @@ where tag.id = updated_values.id;
|
||||
`
|
||||
for _, table := range []string{"album", "media_file"} {
|
||||
start := time.Now()
|
||||
query := rawSQL(fmt.Sprintf(template, table))
|
||||
query := Expr(fmt.Sprintf(template, table))
|
||||
c, err := r.executeSQL(query)
|
||||
log.Debug(r.ctx, "Updated tag counts", "table", table, "elapsed", time.Since(start), "updated", c)
|
||||
if err != nil {
|
||||
|
||||
@@ -41,6 +41,9 @@ func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding,
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Put(t *model.Transcoding) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.put(t.ID, t)
|
||||
return err
|
||||
}
|
||||
@@ -69,6 +72,9 @@ func (r *transcodingRepository) NewInstance() interface{} {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
if !isAdmin(r.ctx) {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
t := entity.(*model.Transcoding)
|
||||
id, err := r.put(t.ID, t)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
@@ -78,6 +84,9 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
t := entity.(*model.Transcoding)
|
||||
t.ID = id
|
||||
_, err := r.put(id, t)
|
||||
@@ -88,6 +97,9 @@ func (r *transcodingRepository) Update(id string, entity interface{}, cols ...st
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Delete(id string) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(Eq{"id": id})
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
|
||||
96
persistence/transcoding_repository_test.go
Normal file
96
persistence/transcoding_repository_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TranscodingRepository", func() {
|
||||
var repo model.TranscodingRepository
|
||||
var adminRepo model.TranscodingRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(GinkgoT().Context())
|
||||
ctx = request.WithUser(ctx, regularUser)
|
||||
repo = NewTranscodingRepository(ctx, GetDBXBuilder())
|
||||
|
||||
adminCtx := log.NewContext(GinkgoT().Context())
|
||||
adminCtx = request.WithUser(adminCtx, adminUser)
|
||||
adminRepo = NewTranscodingRepository(adminCtx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up any transcoding created during the tests
|
||||
tc, err := adminRepo.FindByFormat("test_format")
|
||||
if err == nil {
|
||||
err = adminRepo.(*transcodingRepository).Delete(tc.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Admin User", func() {
|
||||
It("creates a new transcoding", func() {
|
||||
base, err := adminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = adminRepo.Put(&model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err := adminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(base + 1))
|
||||
})
|
||||
|
||||
It("updates an existing transcoding", func() {
|
||||
tr := &model.Transcoding{ID: "upd", Name: "old", TargetFormat: "test_format", DefaultBitRate: 100, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
tr.Name = "updated"
|
||||
err := adminRepo.Put(tr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, err := adminRepo.FindByFormat("test_format")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Name).To(Equal("updated"))
|
||||
})
|
||||
|
||||
It("deletes a transcoding", func() {
|
||||
err := adminRepo.Put(&model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = adminRepo.(*transcodingRepository).Delete("to-delete")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = adminRepo.Get("to-delete")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular User", func() {
|
||||
It("fails to create", func() {
|
||||
err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"})
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("fails to update", func() {
|
||||
tr := &model.Transcoding{ID: "updreg", Name: "old", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
tr.Name = "bad"
|
||||
err := repo.Put(tr)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
//_ = adminRepo.(*transcodingRepository).Delete("updreg")
|
||||
})
|
||||
|
||||
It("fails to delete", func() {
|
||||
tr := &model.Transcoding{ID: "delreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
err := repo.(*transcodingRepository).Delete("delreg")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
//_ = adminRepo.(*transcodingRepository).Delete("delreg")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -32,12 +32,15 @@
|
||||
"participants": "Weitere Beteiligte",
|
||||
"tags": "Weitere Tags",
|
||||
"mappedTags": "Gemappte Tags",
|
||||
"rawTags": "Tag Rohdaten"
|
||||
"rawTags": "Tag Rohdaten",
|
||||
"bitDepth": "Bittiefe",
|
||||
"sampleRate": "Samplerate",
|
||||
"missing": "Fehlend"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Später abspielen",
|
||||
"playNow": "Jetzt abspielen",
|
||||
"addToPlaylist": "Zur Playlist hinzufügen",
|
||||
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
|
||||
"shuffleAll": "Zufallswiedergabe",
|
||||
"download": "Herunterladen",
|
||||
"playNext": "Als nächstes abspielen",
|
||||
@@ -70,14 +73,16 @@
|
||||
"releaseType": "Typ",
|
||||
"grouping": "Gruppierung",
|
||||
"media": "Medium",
|
||||
"mood": "Stimmung"
|
||||
"mood": "Stimmung",
|
||||
"date": "Aufnahmedatum",
|
||||
"missing": "Fehlend"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Abspielen",
|
||||
"playNext": "Als nächstes abspielen",
|
||||
"addToQueue": "Später abspielen",
|
||||
"shuffle": "Zufallswiedergabe",
|
||||
"addToPlaylist": "Zur Playlist hinzufügen",
|
||||
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
|
||||
"download": "Herunterladen",
|
||||
"info": "Mehr Informationen",
|
||||
"share": "Freigabe erstellen"
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Bewertung",
|
||||
"genre": "Genre",
|
||||
"size": "Größe",
|
||||
"role": "Rolle"
|
||||
"role": "Rolle",
|
||||
"missing": "Fehlend"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albuminterpret |||| Albuminterpreten",
|
||||
@@ -172,7 +178,7 @@
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Playlist |||| Playlists",
|
||||
"name": "Wiedergabeliste |||| Wiedergabelisten",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"duration": "Dauer",
|
||||
@@ -186,11 +192,12 @@
|
||||
"path": "Importieren aus"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Titel zur Playlist hinzufügen",
|
||||
"selectPlaylist": "Wiedergabeliste auswählen:",
|
||||
"addNewPlaylist": "\"%{name}\" erstellen",
|
||||
"export": "Exportieren",
|
||||
"makePublic": "Öffentlich machen",
|
||||
"makePrivate": "Privat stellen"
|
||||
"makePrivate": "Privat stellen",
|
||||
"saveQueue": "Warteschlange in Wiedergabeliste speichern"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Duplikate hinzufügen",
|
||||
@@ -235,11 +242,13 @@
|
||||
"updatedAt": "Fehlt seit"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Entfernen"
|
||||
"remove": "Entfernen",
|
||||
"remove_all": "alle entfernen"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Fehlende Datei(en) entfernt"
|
||||
}
|
||||
},
|
||||
"empty": "keine fehlenden Dateien"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -391,10 +400,10 @@
|
||||
"note": "HINWEIS",
|
||||
"transcodingDisabled": "Die Änderung der Transcodierungskonfiguration über die Web-UI ist aus Sicherheitsgründen deaktiviert. Wenn du die Transcodierungsoptionen ändern (bearbeiten oder hinzufügen) möchtest, starte den Server mit der Konfigurationsoption %{config} neu.",
|
||||
"transcodingEnabled": "Navidrome läuft derzeit mit %{config}, wodurch es möglich ist, Systembefehle aus den Transkodierungseinstellungen über die Webschnittstelle auszuführen. Wir empfehlen, es aus Sicherheitsgründen zu deaktivieren und nur bei der Konfiguration von Transkodierungsoptionen zu aktivieren.",
|
||||
"songsAddedToPlaylist": "Einen Titel zur Playlist hinzugefügt |||| %{smart_count} Titel zur Playlist hinzugefügt",
|
||||
"noPlaylistsAvailable": "Keine Playlist verfügbar",
|
||||
"songsAddedToPlaylist": "Einen Titel zur Wiedergabeliste hinzugefügt |||| %{smart_count} Titel zur Wiedergabeliste hinzugefügt",
|
||||
"noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar",
|
||||
"delete_user_title": "Benutzer '%{name}' löschen",
|
||||
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Playlisten und Einstellungen) wirklich löschen?",
|
||||
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?",
|
||||
"notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert",
|
||||
"notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen",
|
||||
"lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert",
|
||||
@@ -419,7 +428,9 @@
|
||||
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "In Zwischenablage kopieren: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Fehlende Dateien entfernen",
|
||||
"remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht."
|
||||
"remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.",
|
||||
"remove_all_missing_title": "Alle fehlenden Dateien entfernen",
|
||||
"remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothek",
|
||||
@@ -447,8 +458,8 @@
|
||||
},
|
||||
"albumList": "Alben",
|
||||
"about": "Über",
|
||||
"playlists": "Playlisten",
|
||||
"sharedPlaylists": "Geteilte Playlisten"
|
||||
"playlists": "Wiedergabelisten",
|
||||
"sharedPlaylists": "Geteilte Wiedergabelisten"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Wiedergabeliste abspielen",
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Schneller Scan",
|
||||
"fullScan": "Kompletter Scan",
|
||||
"serverUptime": "Server-Betriebszeit",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Typ",
|
||||
"status": "Scan Fehler",
|
||||
"elapsedTime": "Laufzeit"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Hotkeys",
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
"tags": "Πρόσθετες Ετικέτες",
|
||||
"mappedTags": "Χαρτογραφημένες ετικέτες",
|
||||
"rawTags": "Ακατέργαστες ετικέτες",
|
||||
"bitDepth": "Λίγο βάθος"
|
||||
"bitDepth": "Λίγο βάθος",
|
||||
"sampleRate": "Ποσοστό δειγματοληψίας",
|
||||
"missing": "Απών"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Αναπαραγωγη Μετα",
|
||||
@@ -72,7 +74,8 @@
|
||||
"grouping": "Ομαδοποίηση",
|
||||
"media": "Μέσα",
|
||||
"mood": "Διάθεση",
|
||||
"date": "Ημερομηνία Ηχογράφησης"
|
||||
"date": "Ημερομηνία Ηχογράφησης",
|
||||
"missing": "Απών"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Αναπαραγωγή",
|
||||
@@ -104,7 +107,8 @@
|
||||
"rating": "Βαθμολογια",
|
||||
"genre": "Είδος",
|
||||
"size": "Μέγεθος",
|
||||
"role": "Ρόλος"
|
||||
"role": "Ρόλος",
|
||||
"missing": "Απών"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Καλλιτέχνης Άλμπουμ |||| Καλλιτέχνες άλμπουμ",
|
||||
@@ -132,7 +136,7 @@
|
||||
"name": "Όνομα",
|
||||
"password": "Κωδικός Πρόσβασης",
|
||||
"createdAt": "Δημιουργήθηκε στις",
|
||||
"changePassword": "Αλλαγή Κωδικού Πρόσβασης;",
|
||||
"changePassword": "Αλλαγή Κωδικού Πρόσβασης?",
|
||||
"currentPassword": "Υπάρχων Κωδικός Πρόσβασης",
|
||||
"newPassword": "Νέος Κωδικός Πρόσβασης",
|
||||
"token": "Token",
|
||||
@@ -192,11 +196,12 @@
|
||||
"addNewPlaylist": "Δημιουργία \"%{name}\"",
|
||||
"export": "Εξαγωγη",
|
||||
"makePublic": "Να γίνει δημόσιο",
|
||||
"makePrivate": "Να γίνει ιδιωτικό"
|
||||
"makePrivate": "Να γίνει ιδιωτικό",
|
||||
"saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών",
|
||||
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε;"
|
||||
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -237,7 +242,8 @@
|
||||
"updatedAt": "Εξαφανίστηκε"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Αφαίρεση"
|
||||
"remove": "Αφαίρεση",
|
||||
"remove_all": "Αφαίρεση όλων"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Λείπει αρχείο(α) αφαιρέθηκε"
|
||||
@@ -305,7 +311,7 @@
|
||||
"skip": "Παράβλεψη",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Κοινοποίηση",
|
||||
"download": "Λήψη "
|
||||
"download": "Λήψη"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ναι",
|
||||
@@ -344,10 +350,10 @@
|
||||
},
|
||||
"message": {
|
||||
"about": "Σχετικά",
|
||||
"are_you_sure": "Είστε σίγουροι;",
|
||||
"bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}; |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count};",
|
||||
"are_you_sure": "Είστε σίγουροι?",
|
||||
"bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}? |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count}?",
|
||||
"bulk_delete_title": "Διαγραφή του %{name} |||| Διαγραφή του %{smart_count} %{name}",
|
||||
"delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο;",
|
||||
"delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο?",
|
||||
"delete_title": "Διαγραφή του %{name} #%{id}",
|
||||
"details": "Λεπτομέρειες",
|
||||
"error": "Παρουσιάστηκε ένα πρόβλημα από τη μεριά του πελάτη και το αίτημα σας δεν μπορεί να ολοκληρωθεί.",
|
||||
@@ -356,12 +362,12 @@
|
||||
"no": "Όχι",
|
||||
"not_found": "Είτε έχετε εισάγει λανθασμένο URL, είτε ακολουθήσατε έναν υπερσύνδεσμο που δεν ισχύει.",
|
||||
"yes": "Ναι",
|
||||
"unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε;"
|
||||
"unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Δεν βρέθηκαν αποτελέσματα",
|
||||
"no_more_results": "Η σελίδα %{page} είναι εκτός ορίων. Δοκιμάστε την προηγούμενη σελίδα.",
|
||||
"page_out_of_boundaries": "Η σελίδα {page} είναι εκτός ορίων",
|
||||
"page_out_of_boundaries": "Η σελίδα %{page} είναι εκτός ορίων",
|
||||
"page_out_from_end": "Δεν είναι δυνατή η πλοήγηση πέραν της τελευταίας σελίδας",
|
||||
"page_out_from_begin": "Δεν είναι δυνατή η πλοήγηση πριν τη σελίδα 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} από %{total}",
|
||||
@@ -397,7 +403,7 @@
|
||||
"songsAddedToPlaylist": "Προστέθηκε 1 τραγούδι στη λίστα αναπαραγωγής |||| Προστέθηκαν %{smart_count} τραγούδια στη λίστα αναπαραγωγής",
|
||||
"noPlaylistsAvailable": "Κανένα διαθέσιμο",
|
||||
"delete_user_title": "Διαγραφή του χρήστη '%{name}'",
|
||||
"delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων);",
|
||||
"delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων)?",
|
||||
"notifications_blocked": "Έχετε μπλοκάρει τις Ειδοποιήσεις από τη σελίδα, μέσω των ρυθμίσεων του περιηγητή ιστού σας",
|
||||
"notifications_not_available": "Αυτός ο περιηγητής ιστού δεν υποστηρίζει ειδοποιήσεις στην επιφάνεια εργασίας ή δεν έχετε πρόσβαση στο Navidrome μέσω https",
|
||||
"lastfmLinkSuccess": "Το Last.fm έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling ενεργοποιήθηκε",
|
||||
@@ -422,7 +428,9 @@
|
||||
"downloadDialogTitle": "Λήψη %{resource} '%{name}'(%{size})",
|
||||
"shareCopyToClipboard": "Αντιγραφή στο πρόχειρο: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Αφαιρέστε τα αρχεία που λείπουν",
|
||||
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων; Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους."
|
||||
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους.",
|
||||
"remove_all_missing_title": "Αφαίρεση όλων των αρχείων που λείπουν",
|
||||
"remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Βιβλιοθήκη",
|
||||
@@ -496,7 +504,10 @@
|
||||
"quickScan": "Γρήγορη Σάρωση",
|
||||
"fullScan": "Πλήρης Σάρωση",
|
||||
"serverUptime": "Λειτουργία Διακομιστή",
|
||||
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ"
|
||||
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
|
||||
"scanType": "Τύπος",
|
||||
"status": "Σφάλμα σάρωσης",
|
||||
"elapsedTime": "Χρόνος που πέρασε"
|
||||
},
|
||||
"help": {
|
||||
"title": "Συντομεύσεις του Navidrome",
|
||||
|
||||
@@ -24,16 +24,18 @@
|
||||
"rating": "Takso",
|
||||
"quality": "Kvalito",
|
||||
"bpm": "Pulsrapideco",
|
||||
"playDate": "",
|
||||
"channels": "",
|
||||
"createdAt": "",
|
||||
"playDate": "Laste Ludita",
|
||||
"channels": "Kanaloj",
|
||||
"createdAt": "Dato de aligo",
|
||||
"grouping": "",
|
||||
"mood": "",
|
||||
"mood": "Humoro",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": "",
|
||||
"bitDepth": ""
|
||||
"tags": "Aldonaj Etikedoj",
|
||||
"mappedTags": "Mapigitaj etikedoj",
|
||||
"rawTags": "Krudaj etikedoj",
|
||||
"bitDepth": "",
|
||||
"sampleRate": "",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ludi Poste",
|
||||
@@ -42,7 +44,7 @@
|
||||
"shuffleAll": "Miksu Ĉiujn",
|
||||
"download": "Elŝuti",
|
||||
"playNext": "Ludu Poste",
|
||||
"info": ""
|
||||
"info": "Akiri Informon"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -60,19 +62,20 @@
|
||||
"updatedAt": "Ĝisdatigita je :",
|
||||
"comment": "Komento",
|
||||
"rating": "Takso",
|
||||
"createdAt": "",
|
||||
"size": "",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": "",
|
||||
"createdAt": "Dato aldonita",
|
||||
"size": "Grando",
|
||||
"originalDate": "Originala",
|
||||
"releaseDate": "Publikiĝis",
|
||||
"releases": "Publikiĝo |||| Publikiĝoj",
|
||||
"released": "Publikiĝis",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"releaseType": "Tipo",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": "",
|
||||
"date": ""
|
||||
"mood": "Humoro",
|
||||
"date": "",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Ludi",
|
||||
@@ -81,43 +84,44 @@
|
||||
"shuffle": "Miksi",
|
||||
"addToPlaylist": "Aldoni al la Ludlisto",
|
||||
"download": "Elŝuti",
|
||||
"info": "",
|
||||
"share": ""
|
||||
"info": "Akiri Informon",
|
||||
"share": "Diskonigi"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Ĉiuj",
|
||||
"random": "Hazarda",
|
||||
"recentlyAdded": "Lastatempe Aldonita",
|
||||
"recentlyPlayed": "Lastatempe Ludita",
|
||||
"random": "Hazardaj",
|
||||
"recentlyAdded": "Lastatempe Aldonitaj",
|
||||
"recentlyPlayed": "Lastatempe Luditaj",
|
||||
"mostPlayed": "Plej Luditaj",
|
||||
"starred": "Stelplena",
|
||||
"topRated": "Plej Alte Taksite"
|
||||
"starred": "Stelplenaj",
|
||||
"topRated": "Plej Alte Taksitaj"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artisto |||| Artistoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"albumCount": "Nombro da albumoj",
|
||||
"songCount": "Kanto kalkula",
|
||||
"playCount": "Teatraĵoj",
|
||||
"albumCount": "Kvanto da Albumoj",
|
||||
"songCount": "Kanta Kalkulo",
|
||||
"playCount": "Ludoj",
|
||||
"rating": "Takso",
|
||||
"genre": "",
|
||||
"size": "",
|
||||
"role": ""
|
||||
"genre": "Ĝenro",
|
||||
"size": "Grando",
|
||||
"role": "",
|
||||
"missing": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"albumartist": "Albuma Artisto |||| Albumaj Artistoj",
|
||||
"artist": "Artisto |||| Artistoj",
|
||||
"composer": "Komponisto |||| Komponistoj",
|
||||
"conductor": "Dirigento |||| Dirigentoj",
|
||||
"lyricist": "Kantoteksisto |||| Kantotekstistoj",
|
||||
"arranger": "Aranĝisto |||| Aranĝistoj",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"mixer": "Miksisto |||| Miksistoj",
|
||||
"remixer": "Remiksisto |||| Remiksistoj",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
}
|
||||
@@ -135,8 +139,8 @@
|
||||
"changePassword": "Ĉu Ŝanĝi Pasvorton?",
|
||||
"currentPassword": "Nuna Pasvorto",
|
||||
"newPassword": "Nova Pasvorto",
|
||||
"token": "",
|
||||
"lastAccessAt": ""
|
||||
"token": "Ĵetono",
|
||||
"lastAccessAt": "Lasta Atingo"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
|
||||
@@ -147,8 +151,8 @@
|
||||
"deleted": "Uzanto forigita"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "",
|
||||
"clickHereForToken": ""
|
||||
"listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.",
|
||||
"clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -161,7 +165,7 @@
|
||||
"userName": "Uzantnomo",
|
||||
"lastSeen": "Laste Vidita Je",
|
||||
"reportRealPath": "Raporti vera pado",
|
||||
"scrobbleEnabled": ""
|
||||
"scrobbleEnabled": "Sendi Scrobbles al eksteraj servoj"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
@@ -191,8 +195,9 @@
|
||||
"selectPlaylist": "Elektu ludliston :",
|
||||
"addNewPlaylist": "Krei \"%{name}\"",
|
||||
"export": "Eksporti",
|
||||
"makePublic": "",
|
||||
"makePrivate": ""
|
||||
"makePublic": "Publikigi",
|
||||
"makePrivate": "Malpublikigi",
|
||||
"saveQueue": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Aldoni duobligitajn kantojn",
|
||||
@@ -200,33 +205,33 @@
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "",
|
||||
"name": "Radio |||| Radioj",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"streamUrl": "",
|
||||
"homePageUrl": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": ""
|
||||
"name": "Nomo",
|
||||
"streamUrl": "Flua Ligilo",
|
||||
"homePageUrl": "Hejmpaĝa Ligilo",
|
||||
"updatedAt": "Ĝisdatiĝis je",
|
||||
"createdAt": "Kreiĝis je"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": ""
|
||||
"playNow": "Ludi Nun"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "",
|
||||
"name": "Diskonigo |||| Diskonigoj",
|
||||
"fields": {
|
||||
"username": "",
|
||||
"url": "",
|
||||
"description": "",
|
||||
"contents": "",
|
||||
"expiresAt": "",
|
||||
"lastVisitedAt": "",
|
||||
"visitCount": "",
|
||||
"format": "",
|
||||
"maxBitRate": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"downloadable": ""
|
||||
"username": "Diskonigite De",
|
||||
"url": "Ligilo",
|
||||
"description": "Priskribo",
|
||||
"contents": "Enhavo",
|
||||
"expiresAt": "Senvalidiĝas",
|
||||
"lastVisitedAt": "Laste Vizitita",
|
||||
"visitCount": "Vizitoj",
|
||||
"format": "Formato",
|
||||
"maxBitRate": "Maks. Bitrapido",
|
||||
"updatedAt": "Ĝisdatiĝis je",
|
||||
"createdAt": "Fariĝis je",
|
||||
"downloadable": "Ĉu Ebligi Elŝutojn?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@@ -237,7 +242,8 @@
|
||||
"updatedAt": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
"remove": "",
|
||||
"remove_all": ""
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
@@ -258,7 +264,7 @@
|
||||
"sign_in": "Ensaluti",
|
||||
"sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi",
|
||||
"logout": "Elsaluti",
|
||||
"insightsCollectionNote": ""
|
||||
"insightsCollectionNote": "Navidrome kolektas anoniman uzdatumon por helpi\nplibonigi la projekton. Alklaku [ĉi tie] por lerni pli kaj\nsupozi permeson se vi volas"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Bonvolu uzi nur literojn kaj ciferojn",
|
||||
@@ -273,7 +279,7 @@
|
||||
"oneOf": "Devas esti unu el: %{options}",
|
||||
"regex": "Devas kongrui kun specifa formato (regexp): %{pattern}",
|
||||
"unique": "Devas esti unika",
|
||||
"url": ""
|
||||
"url": "Devas esti valida ligilo"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Aldoni filtrilon",
|
||||
@@ -303,9 +309,9 @@
|
||||
"close_menu": "Fermu menuon",
|
||||
"unselect": "Malelekti",
|
||||
"skip": "Pasigi",
|
||||
"bulk_actions_mobile": "",
|
||||
"share": "",
|
||||
"download": ""
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Diskonigi",
|
||||
"download": "Elŝuti"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Jes",
|
||||
@@ -381,13 +387,13 @@
|
||||
"i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo",
|
||||
"canceled": "Ago nuligita",
|
||||
"logged_out": "Via seanco finiĝis, bonvolu rekonekti.",
|
||||
"new_version": ""
|
||||
"new_version": "Nova versio haveblas! Bonvolu reŝargi la fenestron."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "",
|
||||
"columnsToDisplay": "Kolumnoj Por Montri",
|
||||
"layout": "Aranĝo",
|
||||
"grid": "Krado",
|
||||
"table": ""
|
||||
"table": "Tabelo"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -400,29 +406,31 @@
|
||||
"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": "",
|
||||
"lastfmLinkSuccess": "Last.fm sukcese ligiĝis kaj scrobbling ebliĝis",
|
||||
"lastfmLinkFailure": "Last.fm ne povis ligiĝi",
|
||||
"lastfmUnlinkSuccess": "Last.fm malligiĝis kaj scrobbling malebliĝis",
|
||||
"lastfmUnlinkFailure": "Last.fm ne povis malligiĝi",
|
||||
"openIn": {
|
||||
"lastfm": "",
|
||||
"musicbrainz": ""
|
||||
"lastfm": "Malfermi en Last.fm",
|
||||
"musicbrainz": "Malfermi en MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "",
|
||||
"listenBrainzLinkSuccess": "",
|
||||
"listenBrainzLinkFailure": "",
|
||||
"listenBrainzUnlinkSuccess": "",
|
||||
"listenBrainzUnlinkFailure": "",
|
||||
"downloadOriginalFormat": "",
|
||||
"shareOriginalFormat": "",
|
||||
"shareDialogTitle": "",
|
||||
"shareBatchDialogTitle": "",
|
||||
"shareSuccess": "",
|
||||
"shareFailure": "",
|
||||
"downloadDialogTitle": "",
|
||||
"shareCopyToClipboard": "",
|
||||
"lastfmLink": "Legi Pli...",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz sukcese ligiĝis kaj scrobbling ebliĝis kiel uzanto: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz ne povis ligiĝi: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz malligiĝis kaj scrobbling malebliĝis",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz ne povis malligiĝi",
|
||||
"downloadOriginalFormat": "Elŝuti en originala formato",
|
||||
"shareOriginalFormat": "Diskonigi en originala formato",
|
||||
"shareDialogTitle": "Diskonigi %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Diskonigi 1 %{resource} |||| Diskonigi %{smart_count} %{resource}",
|
||||
"shareSuccess": "Ligilo kopiiĝis al la tondujo: %{url}",
|
||||
"shareFailure": "Eraro de kopio de ligilo %{url} al la tondujo",
|
||||
"downloadDialogTitle": "Elŝuti %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Kopii al la tondujo: Ctrl+C, Enter",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": ""
|
||||
"remove_missing_content": "Ĉu vi certas, ke vi volas forigi la elektitajn mankajn dosierojn de la datumbazo? Ĉi tio forigos eterne ĉiujn referencojn de ili, inkluzive iliajn ludkvantojn kaj taksojn.",
|
||||
"remove_all_missing_title": "",
|
||||
"remove_all_missing_content": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteko",
|
||||
@@ -436,22 +444,22 @@
|
||||
"language": "Lingvo",
|
||||
"defaultView": "Defaŭlta Vido",
|
||||
"desktop_notifications": "Labortablaj sciigoj",
|
||||
"lastfmScrobbling": "",
|
||||
"listenBrainzScrobbling": "",
|
||||
"replaygain": "",
|
||||
"preAmp": "",
|
||||
"lastfmScrobbling": "Scrobble al Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobble al ListenBrainz",
|
||||
"replaygain": "ReplayGain-Reĝimo",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"gain": {
|
||||
"none": "",
|
||||
"album": "",
|
||||
"track": ""
|
||||
"none": "Malebligita",
|
||||
"album": "Uzi Albuman Songajnon",
|
||||
"track": "Uzi Kantan Songajnon"
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
}
|
||||
},
|
||||
"albumList": "Albumoj",
|
||||
"about": "Pri",
|
||||
"playlists": "",
|
||||
"sharedPlaylists": ""
|
||||
"playlists": "Ludlistoj",
|
||||
"sharedPlaylists": "Diskonigitaj Ludistoj"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Atendovico",
|
||||
@@ -485,7 +493,7 @@
|
||||
"featureRequests": "Trajta peto",
|
||||
"lastInsightsCollection": "",
|
||||
"insights": {
|
||||
"disabled": "",
|
||||
"disabled": "Malebligita",
|
||||
"waiting": ""
|
||||
}
|
||||
}
|
||||
@@ -496,7 +504,10 @@
|
||||
"quickScan": "Rapida Skanado",
|
||||
"fullScan": "Plena Skanado",
|
||||
"serverUptime": "Servila daŭro de funkciado",
|
||||
"serverDown": "SENKONEKTA"
|
||||
"serverDown": "SENKONEKTA",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": ""
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome klavkomando",
|
||||
@@ -509,7 +520,7 @@
|
||||
"vol_up": "Pli volumo",
|
||||
"vol_down": "Malpli volumo",
|
||||
"toggle_love": "Baskuli la stelon de nuna kanto",
|
||||
"current_song": ""
|
||||
"current_song": "Iri al Nuna Kanto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,16 +28,19 @@
|
||||
"channels": "Canales",
|
||||
"createdAt": "Creado el",
|
||||
"grouping": "Agrupación",
|
||||
"mood": "",
|
||||
"mood": "Estado de ánimo",
|
||||
"participants": "Participantes",
|
||||
"tags": "Etiquetas",
|
||||
"mappedTags": "Etiquetas asignadas",
|
||||
"rawTags": "Etiquetas sin procesar"
|
||||
"rawTags": "Etiquetas sin procesar",
|
||||
"bitDepth": "Profundidad de bits",
|
||||
"sampleRate": "Frecuencia de muestreo",
|
||||
"missing": "Faltante"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Reproducir después",
|
||||
"playNow": "Reproducir ahora",
|
||||
"addToPlaylist": "Agregar a la lista de reproducción",
|
||||
"addToPlaylist": "Agregar a la playlist",
|
||||
"shuffleAll": "Todas aleatorias",
|
||||
"download": "Descarga",
|
||||
"playNext": "Siguiente",
|
||||
@@ -69,8 +72,10 @@
|
||||
"catalogNum": "Número de catálogo",
|
||||
"releaseType": "Tipo de lanzamiento",
|
||||
"grouping": "Agrupación",
|
||||
"media": "",
|
||||
"mood": ""
|
||||
"media": "Medios",
|
||||
"mood": "Estado de ánimo",
|
||||
"date": "Fecha de grabación",
|
||||
"missing": "Faltante"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Reproducir",
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Calificación",
|
||||
"genre": "Género",
|
||||
"size": "Tamaño",
|
||||
"role": "Rol"
|
||||
"role": "Rol",
|
||||
"missing": "Faltante"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista del álbum",
|
||||
@@ -190,11 +196,12 @@
|
||||
"addNewPlaylist": "Creada \"%{name}\"",
|
||||
"export": "Exportar",
|
||||
"makePublic": "Hazla pública",
|
||||
"makePrivate": "Hazla privada"
|
||||
"makePrivate": "Hazla privada",
|
||||
"saveQueue": "Guardar la fila de reproducción en una playlist"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Algunas de las canciones seleccionadas están presentes en la lista de reproducción",
|
||||
"song_exist": "Se están agregando duplicados a la lista de reproducción. ¿Quieres agregar los duplicados o omitirlos?"
|
||||
"duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist",
|
||||
"song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -235,11 +242,13 @@
|
||||
"updatedAt": "Actualizado el"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Eliminar"
|
||||
"remove": "Eliminar",
|
||||
"remove_all": "Eliminar todo"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Eliminado"
|
||||
}
|
||||
},
|
||||
"empty": "No hay archivos perdidos"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -419,7 +428,9 @@
|
||||
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
|
||||
"remove_missing_title": "Eliminar elemento faltante",
|
||||
"remove_missing_content": ""
|
||||
"remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
|
||||
"remove_all_missing_title": "Eliminar todos los archivos perdidos",
|
||||
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -451,7 +462,7 @@
|
||||
"sharedPlaylists": "Playlists Compartidas"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Lista de reproducción",
|
||||
"playListsText": "Fila de reproducción",
|
||||
"openText": "Abrir",
|
||||
"closeText": "Cerrar",
|
||||
"notContentText": "Sin música",
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo",
|
||||
"serverUptime": "Uptime del servidor",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Tipo",
|
||||
"status": "Error de escaneo",
|
||||
"elapsedTime": "Tiempo transcurrido"
|
||||
},
|
||||
"help": {
|
||||
"title": "Atajos de teclado de Navidrome",
|
||||
@@ -509,4 +523,4 @@
|
||||
"current_song": "Canción actual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@
|
||||
"year": "Urtea",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"bitRate": "Bit tasa",
|
||||
"bitRate": "Bit-tasa",
|
||||
"bitDepth": "Bit-sakonera",
|
||||
"sampleRate": "Lagin-tasa",
|
||||
"channels": "Kanalak",
|
||||
"discSubtitle": "Diskoaren azpititulua",
|
||||
"starred": "Gogokoa",
|
||||
"comment": "Iruzkina",
|
||||
@@ -25,14 +28,13 @@
|
||||
"quality": "Kalitatea",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Azkenekoz erreproduzitua:",
|
||||
"channels": "Kanalak",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"grouping": "",
|
||||
"mood": "",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": ""
|
||||
"grouping": "Multzokatzea",
|
||||
"mood": "Aldartea",
|
||||
"participants": "Partaide gehiago",
|
||||
"tags": "Traola gehiago",
|
||||
"mappedTags": "Esleitutako traolak",
|
||||
"rawTags": "Traola gordinak"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Erreproduzitu ondoren",
|
||||
@@ -52,25 +54,26 @@
|
||||
"duration": "Iraupena",
|
||||
"songCount": "abesti",
|
||||
"playCount": "Erreprodukzioak",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"name": "Izena",
|
||||
"genre": "Generoa",
|
||||
"compilation": "Konpilazioa",
|
||||
"year": "Urtea",
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"date": "Recording Date",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data:",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"released": "Argitaratua",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": ""
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"recordLabel": "Disketxea",
|
||||
"catalogNum": "Katalogo-zenbakia",
|
||||
"releaseType": "Mota",
|
||||
"grouping": "Multzokatzea",
|
||||
"media": "Multimedia",
|
||||
"mood": "Aldartea"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Erreproduzitu",
|
||||
@@ -84,7 +87,7 @@
|
||||
},
|
||||
"lists": {
|
||||
"all": "Guztiak",
|
||||
"random": "Aleatorioa",
|
||||
"random": "Aleatorioki",
|
||||
"recentlyAdded": "Berriki gehitutakoak",
|
||||
"recentlyPlayed": "Berriki entzundakoak",
|
||||
"mostPlayed": "Gehien entzundakoak",
|
||||
@@ -98,26 +101,26 @@
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
"songCount": "Abesti kopurua",
|
||||
"size": "Tamaina",
|
||||
"playCount": "Erreprodukzio kopurua",
|
||||
"rating": "Balorazioa",
|
||||
"genre": "Generoa",
|
||||
"size": "Tamaina",
|
||||
"role": ""
|
||||
"role": "Rola"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
"albumartist": "Albumeko egilea |||| Albumeko artistak",
|
||||
"artist": "Artista |||| Artistak",
|
||||
"composer": "Konpositorea |||| Konpositoreak",
|
||||
"conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak",
|
||||
"lyricist": "Hitzen egilea |||| Hitzen egileak",
|
||||
"arranger": "Moldatzailea |||| Moldatzaileak",
|
||||
"producer": "Produktorea |||| Produktoreak",
|
||||
"director": "Zuzendaria |||| Zuzendaria",
|
||||
"engineer": "Teknikaria |||| Teknikariak",
|
||||
"mixer": "Nahaslea |||| Nahasleak",
|
||||
"remixer": "Remixerra |||| Remixerrak",
|
||||
"djmixer": "DJ nahaslea |||| DJ nahasleak",
|
||||
"performer": "Interpretatzailea |||| Interpretatzaileak"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -238,7 +241,8 @@
|
||||
"updatedAt": "Desagertze-data:"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Kendu"
|
||||
"remove": "Kendu",
|
||||
"remove_all": "Kendu guztia"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Faltan zeuden fitxategiak kendu dira"
|
||||
@@ -258,7 +262,7 @@
|
||||
"sign_in": "Sartu",
|
||||
"sign_in_error": "Autentifikazioak huts egin du, saiatu berriro",
|
||||
"logout": "Amaitu saioa",
|
||||
"insightsCollectionNote": ""
|
||||
"insightsCollectionNote": "Navidromek erabilera-datu anonimoak biltzen ditu\nproiektua hobetzen laguntzeko. Egin klik [hemen]\ngehiago ikasteko, eta datuak ez biltzeko eskatzeko,\nhala nahi izanez gero."
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Erabili hizkiak eta zenbakiak bakarrik",
|
||||
@@ -398,31 +402,33 @@
|
||||
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
|
||||
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
|
||||
"delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?",
|
||||
"remove_missing_title": "Kendu faltan dauden fitxategiak",
|
||||
"remove_missing_content": "Ziur hautatutako fitxategiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.",
|
||||
"remove_all_missing_title": "Kendu faltan dauden fitxategi guztiak",
|
||||
"remove_all_missing_content": "Ziur aurkitu ez diren fitxategi guztiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.",
|
||||
"notifications_blocked": "Nabigatzaileak jakinarazpenak blokeatzen ditu",
|
||||
"notifications_not_available": "Nabigatzaile hau ez da jakinarazpenekin bateragarria edo Navidrome ez da HTTPS erabiltzen ari",
|
||||
"lastfmLinkSuccess": "Last.fm konektatuta dago eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea gaituta dago",
|
||||
"lastfmLinkFailure": "Ezin izan da Last.fm-rekin konektatu",
|
||||
"lastfmUnlinkSuccess": "Last.fm deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea ezgaitu da",
|
||||
"lastfmUnlinkFailure": "Ezin izan da Last.fm deskonektatu",
|
||||
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
|
||||
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"openIn": {
|
||||
"lastfm": "Ikusi Last.fm-n",
|
||||
"musicbrainz": "Ikusi MusicBrainz-en"
|
||||
},
|
||||
"lastfmLink": "Irakurri gehiago…",
|
||||
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
|
||||
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua",
|
||||
"shareOriginalFormat": "Partekatu jatorrizko formatua",
|
||||
"shareDialogTitle": "Partekatu '%{name}' %{resource}",
|
||||
"shareBatchDialogTitle": "Partekatu %{resource} bat |||| Partekatu %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
|
||||
"shareSuccess": "URLa arbelera kopiatu da: %{url}",
|
||||
"shareFailure": "Errorea %{url} URLa arbelera kopiatzean",
|
||||
"downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})",
|
||||
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": ""
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Liburutegia",
|
||||
@@ -436,6 +442,7 @@
|
||||
"language": "Hizkuntza",
|
||||
"defaultView": "Bista, defektuz",
|
||||
"desktop_notifications": "Mahaigaineko jakinarazpenak",
|
||||
"lastfmNotConfigured": "Last.fm-ren API-gakoa ez dago konfiguratuta",
|
||||
"lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak",
|
||||
"listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak",
|
||||
"replaygain": "ReplayGain modua",
|
||||
@@ -444,14 +451,13 @@
|
||||
"none": "Bat ere ez",
|
||||
"album": "Albuma",
|
||||
"track": "Pista"
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albumak",
|
||||
"about": "Honi buruz",
|
||||
"playlists": "Zerrendak",
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak"
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
||||
"about": "Honi buruz"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Erreprodukzio-zerrenda",
|
||||
@@ -483,10 +489,10 @@
|
||||
"homepage": "Hasierako orria",
|
||||
"source": "Iturburu kodea",
|
||||
"featureRequests": "Eskatu ezaugarria",
|
||||
"lastInsightsCollection": "",
|
||||
"lastInsightsCollection": "Bildutako azken datuak",
|
||||
"insights": {
|
||||
"disabled": "",
|
||||
"waiting": ""
|
||||
"disabled": "Ezgaituta",
|
||||
"waiting": "Zain"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -496,7 +502,10 @@
|
||||
"quickScan": "Arakatze azkarra",
|
||||
"fullScan": "Arakatze sakona",
|
||||
"serverUptime": "Zerbitzariak piztuta daraman denbora",
|
||||
"serverDown": "LINEAZ KANPO"
|
||||
"serverDown": "LINEAZ KANPO",
|
||||
"scanType": "Mota",
|
||||
"status": "Errorea arakatzean",
|
||||
"elapsedTime": "Igarotako denbora"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidromeren laster-teklak",
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
"participants": "Lisäosallistujat",
|
||||
"tags": "Lisätunnisteet",
|
||||
"mappedTags": "Mäpättyt tunnisteet",
|
||||
"rawTags": "Raakatunnisteet"
|
||||
"rawTags": "Raakatunnisteet",
|
||||
"bitDepth": "Bittisyvyys",
|
||||
"sampleRate": "Näytteenottotaajuus",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lisää jonoon",
|
||||
@@ -70,7 +73,9 @@
|
||||
"releaseType": "Tyyppi",
|
||||
"grouping": "Ryhmittely",
|
||||
"media": "Media",
|
||||
"mood": "Tunnelma"
|
||||
"mood": "Tunnelma",
|
||||
"date": "Tallennuspäivä",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Soita",
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Arvostelu",
|
||||
"genre": "Tyylilaji",
|
||||
"size": "Koko",
|
||||
"role": "Rooli"
|
||||
"role": "Rooli",
|
||||
"missing": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albumitaiteilija |||| Albumitaiteilijat",
|
||||
@@ -190,7 +196,8 @@
|
||||
"addNewPlaylist": "Luo \"%{name}\"",
|
||||
"export": "Vie",
|
||||
"makePublic": "Tee julkinen",
|
||||
"makePrivate": "Tee yksityinen"
|
||||
"makePrivate": "Tee yksityinen",
|
||||
"saveQueue": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Lisää olemassa oleva kappale",
|
||||
@@ -235,11 +242,13 @@
|
||||
"updatedAt": "Katosi"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Poista"
|
||||
"remove": "Poista",
|
||||
"remove_all": ""
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Puuttuvat tiedostot poistettu"
|
||||
}
|
||||
},
|
||||
"empty": "Ei puuttuvia tiedostoja"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -419,7 +428,9 @@
|
||||
"downloadDialogTitle": "Lataa %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Kopio leikepöydälle: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Poista puuttuvat tiedostot",
|
||||
"remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut."
|
||||
"remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut.",
|
||||
"remove_all_missing_title": "",
|
||||
"remove_all_missing_content": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Kirjasto",
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Nopea tarkistus",
|
||||
"fullScan": "Täysi tarkistus",
|
||||
"serverUptime": "Palvelun käyttöaika",
|
||||
"serverDown": "SAMMUTETTU"
|
||||
"serverDown": "SAMMUTETTU",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": ""
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome pikapainikkeet",
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
"tags": "Étiquettes supplémentaires",
|
||||
"mappedTags": "Étiquettes correspondantes",
|
||||
"rawTags": "Étiquettes brutes",
|
||||
"bitDepth": "Profondeur de bit"
|
||||
"bitDepth": "Profondeur de bits",
|
||||
"sampleRate": "Fréquence d'échantillonnage",
|
||||
"missing": "Manquant"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ajouter à la file",
|
||||
@@ -71,7 +73,9 @@
|
||||
"releaseType": "Type",
|
||||
"grouping": "Regroupement",
|
||||
"media": "Média",
|
||||
"mood": "Humeur"
|
||||
"mood": "Humeur",
|
||||
"date": "Date d'enregistrement",
|
||||
"missing": "Manquant"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Lire",
|
||||
@@ -103,7 +107,8 @@
|
||||
"rating": "Classement",
|
||||
"genre": "Genre",
|
||||
"size": "Taille",
|
||||
"role": "Rôle"
|
||||
"role": "Rôle",
|
||||
"missing": "Manquant"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artiste de l'album |||| Artistes de l'album",
|
||||
@@ -191,7 +196,8 @@
|
||||
"addNewPlaylist": "Créer \"%{name}\"",
|
||||
"export": "Exporter",
|
||||
"makePublic": "Rendre publique",
|
||||
"makePrivate": "Rendre privée"
|
||||
"makePrivate": "Rendre privée",
|
||||
"saveQueue": "Sauvegarder la file de lecture dans la playlist"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Pistes déjà présentes dans la playlist",
|
||||
@@ -236,7 +242,8 @@
|
||||
"updatedAt": "A disparu le"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Supprimer"
|
||||
"remove": "Supprimer",
|
||||
"remove_all": "Tout supprimer"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Fichier(s) manquant(s) supprimé(s)"
|
||||
@@ -421,7 +428,9 @@
|
||||
"downloadDialogTitle": "Télécharger %{resource} '%{name}' (%{size})",
|
||||
"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"
|
||||
"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",
|
||||
"remove_all_missing_title": "Supprimer tous les fichiers manquants",
|
||||
"remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothèque",
|
||||
@@ -495,7 +504,10 @@
|
||||
"quickScan": "Scan rapide",
|
||||
"fullScan": "Scan complet",
|
||||
"serverUptime": "Disponibilité du serveur",
|
||||
"serverDown": "HORS LIGNE"
|
||||
"serverDown": "HORS LIGNE",
|
||||
"scanType": "Type",
|
||||
"status": "Erreur de scan",
|
||||
"elapsedTime": "Temps écoulé"
|
||||
},
|
||||
"help": {
|
||||
"title": "Raccourcis Navidrome",
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"size": "Fájlméret",
|
||||
"updatedAt": "Legutóbb frissítve",
|
||||
"bitRate": "Bitráta",
|
||||
"bitDepth": "Bitmélység",
|
||||
"sampleRate": "Mintavételezési frekvencia",
|
||||
"discSubtitle": "Lemezfelirat",
|
||||
"starred": "Kedvenc",
|
||||
"comment": "Megjegyzés",
|
||||
@@ -32,7 +34,8 @@
|
||||
"participants": "További résztvevők",
|
||||
"tags": "További címkék",
|
||||
"mappedTags": "Feldolgozott címkék",
|
||||
"rawTags": "Nyers címkék"
|
||||
"rawTags": "Nyers címkék",
|
||||
"missing": "Hiányzó"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lejátszás útolsóként",
|
||||
@@ -56,6 +59,7 @@
|
||||
"genre": "Stílus",
|
||||
"compilation": "Válogatásalbum",
|
||||
"year": "Év",
|
||||
"date": "Felvétel dátuma",
|
||||
"updatedAt": "Legutóbb frissítve",
|
||||
"comment": "Megjegyzés",
|
||||
"rating": "Értékelés",
|
||||
@@ -70,7 +74,8 @@
|
||||
"releaseType": "Típus",
|
||||
"grouping": "Csoportosítás",
|
||||
"media": "Média",
|
||||
"mood": "Hangulat"
|
||||
"mood": "Hangulat",
|
||||
"missing": "Hiányzó"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Lejátszás",
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Értékelés",
|
||||
"genre": "Stílus",
|
||||
"size": "Méret",
|
||||
"role": "Szerep"
|
||||
"role": "Szerep",
|
||||
"missing": "Hiányzó"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Album előadó |||| Album előadók",
|
||||
@@ -189,6 +195,7 @@
|
||||
"selectPlaylist": "Válassz egy lejátszási listát:",
|
||||
"addNewPlaylist": "\"%{name}\" létrehozása",
|
||||
"export": "Exportálás",
|
||||
"saveQueue": "Műsorlista elmentése lejátszási listaként",
|
||||
"makePublic": "Publikussá tétel",
|
||||
"makePrivate": "Priváttá tétel"
|
||||
},
|
||||
@@ -229,13 +236,15 @@
|
||||
},
|
||||
"missing": {
|
||||
"name": "Hiányzó fájl|||| Hiányzó fájlok",
|
||||
"empty": "Nincsenek hiányzó fájlok",
|
||||
"fields": {
|
||||
"path": "Útvonal",
|
||||
"size": "Méret",
|
||||
"updatedAt": "Eltűnt ekkor:"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Eltávolítás"
|
||||
"remove": "Eltávolítás",
|
||||
"remove_all": "Összes eltávolítása"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Hiányzó fájl(ok) eltávolítva"
|
||||
@@ -395,6 +404,8 @@
|
||||
"noPlaylistsAvailable": "Nem áll rendelkezésre",
|
||||
"delete_user_title": "Felhasználó törlése '%{name}'",
|
||||
"delete_user_content": "Biztos, hogy törölni akarod ezt a felhasználót az adataival (beállítások és lejátszási listák) együtt?",
|
||||
"remove_all_missing_title": "Összes hiányzó fájl eltávolítása",
|
||||
"remove_all_missing_content": "Biztos, hogy minden hiányzó fájlt törölni akarsz az adatbázisból? Ez minden hozzájuk fűződő referenciát törölni fog, beleértve a lejátszásaikat és értékeléseiket.",
|
||||
"notifications_blocked": "A böngésződ beállításaiban letiltottad az értesítéseket erre az oldalra.",
|
||||
"notifications_not_available": "Ez a böngésző nem támogatja az asztali értesítéseket, vagy a Navidrome-ot nem https-en keresztül használod.",
|
||||
"lastfmLinkSuccess": "Sikeresen összekapcsolva Last.fm-el és halgatott számok küldése engedélyezve.",
|
||||
@@ -406,7 +417,7 @@
|
||||
"musicbrainz": "Megnyitás MusicBrainz-ben"
|
||||
},
|
||||
"lastfmLink": "Bővebben...",
|
||||
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el és halgatott számok küldése %{user} felhasználónak engedélyezve.",
|
||||
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el. Halgatott számok küldése %{user} felhasználónak engedélyezve.",
|
||||
"listenBrainzLinkFailure": "Nem lehet kapcsolódni a Listenbrainz-hez: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz Last.fm leválasztva és a halgatott számok küldése kikapcsolva.",
|
||||
"listenBrainzUnlinkFailure": "Nem sikerült leválasztani a ListenBrainz-et.",
|
||||
@@ -451,7 +462,7 @@
|
||||
"sharedPlaylists": "Megosztott lej. listák"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Lejátszási lista",
|
||||
"playListsText": "Műsorlista",
|
||||
"openText": "Megnyitás",
|
||||
"closeText": "Bezárás",
|
||||
"notContentText": "Nincs zene",
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Gyors beolvasás",
|
||||
"fullScan": "Teljes beolvasás",
|
||||
"serverUptime": "Szerver üzemidő",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Típus",
|
||||
"status": "Szkennelési hiba",
|
||||
"elapsedTime": "Eltelt idő"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Gyorsbillentyűk",
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
"participants": "Partisipan tambahan",
|
||||
"tags": "Tag tambahan",
|
||||
"mappedTags": "Tag yang dipetakan",
|
||||
"rawTags": "Tag raw"
|
||||
"rawTags": "Tag raw",
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "Sample rate",
|
||||
"missing": "Hilang"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Tambah ke antrean",
|
||||
@@ -70,7 +73,9 @@
|
||||
"releaseType": "Tipe",
|
||||
"grouping": "Pengelompokkan",
|
||||
"media": "Media",
|
||||
"mood": "Mood"
|
||||
"mood": "Mood",
|
||||
"date": "Tanggal Perekaman",
|
||||
"missing": "Hilang"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Putar",
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Peringkat",
|
||||
"genre": "Genre",
|
||||
"size": "Ukuran",
|
||||
"role": "Peran"
|
||||
"role": "Peran",
|
||||
"missing": "Hilang"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artis Album |||| Artis Album",
|
||||
@@ -163,7 +169,7 @@
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transkode |||| Transkode",
|
||||
"name": "Transkoding |||| Transkoding",
|
||||
"fields": {
|
||||
"name": "Nama",
|
||||
"targetFormat": "Target Format",
|
||||
@@ -190,7 +196,8 @@
|
||||
"addNewPlaylist": "Buat \"%{name}\"",
|
||||
"export": "Ekspor",
|
||||
"makePublic": "Jadikan Publik",
|
||||
"makePrivate": "Jadikan Pribadi"
|
||||
"makePrivate": "Jadikan Pribadi",
|
||||
"saveQueue": "Simpan Antrean ke Playlist"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Tambahkan lagu duplikat",
|
||||
@@ -235,11 +242,13 @@
|
||||
"updatedAt": "Tidak muncul di"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Hapus"
|
||||
"remove": "Hapus",
|
||||
"remove_all": "Hapus Semua"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "File yang hilang dihapus"
|
||||
}
|
||||
},
|
||||
"empty": "Tidak ada File yang Hilang"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -277,7 +286,7 @@
|
||||
"add": "Tambah",
|
||||
"back": "Kembali",
|
||||
"bulk_actions": "1 item dipilih |||| %{smart_count} item dipilih",
|
||||
"cancel": "Batalkan",
|
||||
"cancel": "Batal",
|
||||
"clear_input_value": "Hapus",
|
||||
"clone": "Klon",
|
||||
"confirm": "Konfirmasi",
|
||||
@@ -292,7 +301,7 @@
|
||||
"save": "Simpan",
|
||||
"search": "Cari",
|
||||
"show": "Tampilkan",
|
||||
"sort": "Sortir",
|
||||
"sort": "Urutkan",
|
||||
"undo": "Batalkan",
|
||||
"expand": "Luaskan",
|
||||
"close": "Tutup",
|
||||
@@ -312,7 +321,7 @@
|
||||
"create": "Buat %{name}",
|
||||
"dashboard": "Dasbor",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Ada yang tidak beres",
|
||||
"error": "Terjadi kesalahan",
|
||||
"list": "%{name}",
|
||||
"loading": "Memuat",
|
||||
"not_found": "Tidak ditemukan",
|
||||
@@ -356,7 +365,7 @@
|
||||
"unsaved_changes": "Beberapa perubahan tidak disimpan. Apakah Kamu yakin ingin mengabaikannya?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Tidak ada hasil yang ditemukan",
|
||||
"no_results": "Hasil tidak ditemukan",
|
||||
"no_more_results": "Nomor halaman %{page} melampaui batas. Coba halaman sebelumnya.",
|
||||
"page_out_of_boundaries": "Nomor halaman %{page} melampaui batas",
|
||||
"page_out_from_end": "Tidak dapat menelusuri sebelum halaman terakhir",
|
||||
@@ -371,8 +380,8 @@
|
||||
"updated": "Elemen diperbarui |||| %{smart_count} elemen diperbarui",
|
||||
"created": "Elemen dibuat",
|
||||
"deleted": "Elemen dihapus |||| %{smart_count} elemen dihapus",
|
||||
"bad_item": "Elemen salah",
|
||||
"item_doesnt_exist": "Tidak ada elemen",
|
||||
"bad_item": "Kesalahan elemen",
|
||||
"item_doesnt_exist": "Elemen tidak ditemukan",
|
||||
"http_error": "Kesalahan komunikasi peladen",
|
||||
"data_provider_error": "dataProvider galat. Periksa konsol untuk detailnya.",
|
||||
"i18n_error": "Tidak dapat memuat terjemahan untuk bahasa yang diatur",
|
||||
@@ -419,7 +428,9 @@
|
||||
"downloadDialogTitle": "Unduh %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Salin ke papan klip: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Hapus file yang hilang",
|
||||
"remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya."
|
||||
"remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya.",
|
||||
"remove_all_missing_title": "Hapus semua file yang hilang",
|
||||
"remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Pustaka",
|
||||
@@ -451,7 +462,7 @@
|
||||
"sharedPlaylists": "Playlist yang Dibagikan"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Mainkan Antrean",
|
||||
"playListsText": "Putar Antrean",
|
||||
"openText": "Buka",
|
||||
"closeText": "Tutup",
|
||||
"notContentText": "Tidak ada musik",
|
||||
@@ -471,7 +482,7 @@
|
||||
"playModeText": {
|
||||
"order": "Berurutan",
|
||||
"orderLoop": "Ulang",
|
||||
"singleLoop": "Ulangi Satu",
|
||||
"singleLoop": "Ulangi Sekali",
|
||||
"shufflePlay": "Acak"
|
||||
}
|
||||
},
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Pemindaian Cepat",
|
||||
"fullScan": "Pemindaian Penuh",
|
||||
"serverUptime": "Waktu Aktif Peladen",
|
||||
"serverDown": "LURING"
|
||||
"serverDown": "LURING",
|
||||
"scanType": "Tipe",
|
||||
"status": "Kesalahan Memindai",
|
||||
"elapsedTime": "Waktu Berakhir"
|
||||
},
|
||||
"help": {
|
||||
"title": "Tombol Pintasan Navidrome",
|
||||
|
||||
@@ -1,460 +1,518 @@
|
||||
{
|
||||
"languageName": "한국어",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "노래 |||| 노래들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"duration": "시간",
|
||||
"trackNumber": "#",
|
||||
"playCount": "재생 횟수",
|
||||
"title": "제목",
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"size": "파일 크기",
|
||||
"updatedAt": "업데이트됨",
|
||||
"bitRate": "비트레이트",
|
||||
"discSubtitle": "디스크 서브타이틀",
|
||||
"starred": "즐겨찾기",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"quality": "품질",
|
||||
"bpm": "BPM",
|
||||
"playDate": "마지막 재생",
|
||||
"channels": "채널",
|
||||
"createdAt": "추가된 날짜"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "나중에 재생",
|
||||
"playNow": "지금 재생",
|
||||
"addToPlaylist": "재생목록에 추가",
|
||||
"shuffleAll": "모든 노래 셔플",
|
||||
"download": "다운로드",
|
||||
"playNext": "다음 재생",
|
||||
"info": "정보"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "앨범 |||| 앨범들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"artist": "아티스트",
|
||||
"duration": "시간",
|
||||
"songCount": "노래",
|
||||
"playCount": "재생 횟수",
|
||||
"name": "이름",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"updatedAt": "업데이트됨",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"createdAt": "추가된 날짜",
|
||||
"size": "크기",
|
||||
"originalDate": "오리지널",
|
||||
"releaseDate": "발매일",
|
||||
"releases": "발매 음반 |||| 발매 음반들",
|
||||
"released": "발매됨"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "재생",
|
||||
"playNext": "다음에 재생",
|
||||
"addToQueue": "나중에 재생",
|
||||
"shuffle": "셔플",
|
||||
"addToPlaylist": "재생목록 추가",
|
||||
"download": "다운로드",
|
||||
"info": "정보",
|
||||
"share": "공유"
|
||||
},
|
||||
"lists": {
|
||||
"all": "모두",
|
||||
"random": "랜덤",
|
||||
"recentlyAdded": "최근 추가됨",
|
||||
"recentlyPlayed": "최근 재생됨",
|
||||
"mostPlayed": "가장 많이 재생됨",
|
||||
"starred": "즐겨찾기",
|
||||
"topRated": "높은 평가"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "아티스트 |||| 아티스트들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"albumCount": "앨범 수",
|
||||
"songCount": "노래 수",
|
||||
"playCount": "재생 횟수",
|
||||
"rating": "평가",
|
||||
"genre": "장르",
|
||||
"size": "크기"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "사용자 |||| 사용자들",
|
||||
"fields": {
|
||||
"userName": "사용자이름",
|
||||
"isAdmin": "관리자",
|
||||
"lastLoginAt": "마지막 로그인",
|
||||
"updatedAt": "업데이트됨",
|
||||
"name": "이름",
|
||||
"password": "비밀번호",
|
||||
"createdAt": "생성됨",
|
||||
"changePassword": "비밀번호를 변경할까요?",
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"newPassword": "새 비밀번호",
|
||||
"token": "토큰"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "이름 변경 사항은 다음 로그인 이후에 반영됨"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "사용자 생성됨",
|
||||
"updated": "사용자 업데이트됨",
|
||||
"deleted": "사용자 삭제됨"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "플레이어 |||| 플레이어들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"transcodingId": "트랜스코딩",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"client": "클라이언트",
|
||||
"userName": "사용자이름",
|
||||
"lastSeen": "마지막으로 봤음",
|
||||
"reportRealPath": "실제 경로 보고서",
|
||||
"scrobbleEnabled": "외부 서비스에 스크로블 보내기"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "트랜스코딩 |||| 트랜스코딩들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"targetFormat": "대상 포맷",
|
||||
"defaultBitRate": "기본 비트레이트",
|
||||
"command": "명령"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "재생목록 |||| 재생목록들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"duration": "지속",
|
||||
"ownerName": "소유자",
|
||||
"public": "공개",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"songCount": "노래",
|
||||
"comment": "댓글",
|
||||
"sync": "자동 가져오기",
|
||||
"path": "다음에서 가져오기"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "재생목록 선택:",
|
||||
"addNewPlaylist": "\"%{name}\" 만들기",
|
||||
"export": "내보내기",
|
||||
"makePublic": "공개",
|
||||
"makePrivate": "비공개"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "중복된 노래 추가",
|
||||
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "라디오 |||| 라디오들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"streamUrl": "스트리밍 URL",
|
||||
"homePageUrl": "홈페이지 URL",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "지금 재생"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "공유 |||| 공유되는 것들",
|
||||
"fields": {
|
||||
"username": "공유됨",
|
||||
"url": "URL",
|
||||
"description": "설명",
|
||||
"contents": "컨텐츠",
|
||||
"expiresAt": "만료",
|
||||
"lastVisitedAt": "마지막 방문",
|
||||
"visitCount": "방문 수",
|
||||
"format": "포맷",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"downloadable": "다운로드를 허용할까요?"
|
||||
}
|
||||
}
|
||||
"languageName": "한국어",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "노래 |||| 노래들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"duration": "시간",
|
||||
"trackNumber": "#",
|
||||
"playCount": "재생 횟수",
|
||||
"title": "제목",
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"size": "파일 크기",
|
||||
"updatedAt": "업데이트됨",
|
||||
"bitRate": "비트레이트",
|
||||
"bitDepth": "비트 심도",
|
||||
"sampleRate": "샘플레이트",
|
||||
"channels": "채널",
|
||||
"discSubtitle": "디스크 서브타이틀",
|
||||
"starred": "즐겨찾기",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"quality": "품질",
|
||||
"bpm": "BPM",
|
||||
"playDate": "마지막 재생",
|
||||
"createdAt": "추가된 날짜",
|
||||
"grouping": "그룹",
|
||||
"mood": "분위기",
|
||||
"participants": "추가 참가자",
|
||||
"tags": "추가 태그",
|
||||
"mappedTags": "매핑된 태그",
|
||||
"rawTags": "원시 태그"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "나중에 재생",
|
||||
"playNow": "지금 재생",
|
||||
"addToPlaylist": "재생목록에 추가",
|
||||
"shuffleAll": "모든 노래 셔플",
|
||||
"download": "다운로드",
|
||||
"playNext": "다음 재생",
|
||||
"info": "정보 얻기"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Navidrome을 설치해 주셔서 감사합니다!",
|
||||
"welcome2": "관리자를 만들고 시작해 보세요",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"buttonCreateAdmin": "관리자 만들기",
|
||||
"auth_check_error": "계속하려면 로그인하세요",
|
||||
"user_menu": "프로파일",
|
||||
"username": "사용자이름",
|
||||
"password": "비밀번호",
|
||||
"sign_in": "가입",
|
||||
"sign_in_error": "인증에 실패했습니다. 다시 시도하세요",
|
||||
"logout": "로그아웃"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "문자와 숫자만 사용하세요",
|
||||
"passwordDoesNotMatch": "비밀번호가 일치하지 않음",
|
||||
"required": "필수 항목임",
|
||||
"minLength": "%{min}자 이하여야 함",
|
||||
"maxLength": "%{max}자 이하여야 함",
|
||||
"minValue": "%{min}자 이상이어야 함",
|
||||
"maxValue": "%{max}자 이하여야 함",
|
||||
"number": "숫자여야 함",
|
||||
"email": "유효한 이메일이어야 함",
|
||||
"oneOf": "다음 중 하나여야 함: %{options}",
|
||||
"regex": "특정 형식(정규식)과 일치해야 함: %{pattern}",
|
||||
"unique": "고유해야 함",
|
||||
"url": "유효한 URL이어야 함"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "필터 추가",
|
||||
"add": "추가",
|
||||
"back": "뒤로 가기",
|
||||
"bulk_actions": "1 개 항목이 선택되었음 |||| %{smart_count} 개 항목이 선택되었음",
|
||||
"cancel": "취소",
|
||||
"clear_input_value": "값 지우기",
|
||||
"clone": "복제",
|
||||
"confirm": "확인",
|
||||
"create": "만들기",
|
||||
"delete": "삭제",
|
||||
"edit": "편집",
|
||||
"export": "내보내기",
|
||||
"list": "목록",
|
||||
"refresh": "새로 고침",
|
||||
"remove_filter": "이 필터 제거",
|
||||
"remove": "제거",
|
||||
"save": "저장",
|
||||
"search": "검색",
|
||||
"show": "표시",
|
||||
"sort": "정렬",
|
||||
"undo": "실행 취소",
|
||||
"expand": "확장",
|
||||
"close": "닫기",
|
||||
"open_menu": "메뉴 열기",
|
||||
"close_menu": "메뉴 닫기",
|
||||
"unselect": "선택 해제",
|
||||
"skip": "건너뛰기",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "공유",
|
||||
"download": "다운로드"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "예",
|
||||
"false": "아니요"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} 만들기",
|
||||
"dashboard": "대시보드",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "문제가 발생하였음",
|
||||
"list": "%{name}",
|
||||
"loading": "로딩 중",
|
||||
"not_found": "찾을 수 없음",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "아직 %{name}이(가) 없습니다.",
|
||||
"invite": "추가할까요?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "업로드할 파일을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 파일을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "업로드할 사진을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 사진을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "참조 데이터를 찾을 수 없습니다.",
|
||||
"many_missing": "연관된 참조 중 적어도 하나는 더 이상 사용할 수 없는 것 같습니다.",
|
||||
"single_missing": "연관된 참조는 더 이상 사용할 수 없는 것 같습니다."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "비밀번호 숨기기",
|
||||
"toggle_hidden": "비밀번호 표시"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "정보",
|
||||
"are_you_sure": "확실한가요?",
|
||||
"bulk_delete_content": "이 %{name}을(를) 삭제할까요? |||| 이 %{smart_count} 개의 항목을 삭제할까요?",
|
||||
"bulk_delete_title": "%{name} 삭제 |||| %{smart_count} %{name} 삭제",
|
||||
"delete_content": "이 항목을 삭제할까요?",
|
||||
"delete_title": "%{name} #%{id} 삭제",
|
||||
"details": "상세 정보",
|
||||
"error": "클라이언트 오류가 발생하여 요청을 완료할 수 없습니다.",
|
||||
"invalid_form": "양식이 유효하지 않습니다. 오류를 확인하세요",
|
||||
"loading": "페이지가 로드 중입니다. 잠시만 기다려 주세요",
|
||||
"no": "아니요",
|
||||
"not_found": "잘못된 URL을 입력했거나 잘못된 링크를 클릭했습니다.",
|
||||
"yes": "예",
|
||||
"unsaved_changes": "일부 변경 사항이 저장되지 않았습니다. 무시할까요?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "결과를 찾을 수 없음",
|
||||
"no_more_results": "페이지 번호 %{page}이(가) 경계를 벗어났습니다. 이전 페이지를 시도해 보세요.",
|
||||
"page_out_of_boundaries": "페이지 번호 %{page}이(가) 경계를 벗어남",
|
||||
"page_out_from_end": "마지막 페이지 뒤로 갈 수 없음",
|
||||
"page_out_from_begin": "첫 페이지 앞으로 갈 수 없음",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
|
||||
"page_rows_per_page": "페이지당 항목:",
|
||||
"next": "다음",
|
||||
"prev": "이전",
|
||||
"skip_nav": "콘텐츠 건너뛰기"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "요소 업데이트됨 |||| %{smart_count} 개 요소 업데이트됨",
|
||||
"created": "요소 생성됨",
|
||||
"deleted": "요소 삭제됨 |||| %{smart_count} 개 요소 삭제됨",
|
||||
"bad_item": "잘못된 요소",
|
||||
"item_doesnt_exist": "요소가 존재하지 않음",
|
||||
"http_error": "서버 통신 오류",
|
||||
"data_provider_error": "dataProvider 오류입니다. 자세한 내용은 콘솔을 확인하세요.",
|
||||
"i18n_error": "지정된 언어에 대한 번역을 로드할 수 없음",
|
||||
"canceled": "작업이 취소됨",
|
||||
"logged_out": "세션이 종료되었습니다. 다시 연결하세요.",
|
||||
"new_version": "새로운 버전이 출시되었습니다! 이 창을 새로 고침하세요."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "표시할 열",
|
||||
"layout": "레이아웃",
|
||||
"grid": "격자",
|
||||
"table": "표"
|
||||
}
|
||||
"album": {
|
||||
"name": "앨범 |||| 앨범들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"artist": "아티스트",
|
||||
"duration": "시간",
|
||||
"songCount": "노래",
|
||||
"playCount": "재생 횟수",
|
||||
"size": "크기",
|
||||
"name": "이름",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"date": "녹음 날짜",
|
||||
"originalDate": "오리지널",
|
||||
"releaseDate": "발매일",
|
||||
"releases": "발매 음반 |||| 발매 음반들",
|
||||
"released": "발매됨",
|
||||
"updatedAt": "업데이트됨",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"createdAt": "추가된 날짜",
|
||||
"recordLabel": "레이블",
|
||||
"catalogNum": "카탈로그 번호",
|
||||
"releaseType": "유형",
|
||||
"grouping": "그룹",
|
||||
"media": "미디어",
|
||||
"mood": "분위기"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "재생",
|
||||
"playNext": "다음 재생",
|
||||
"addToQueue": "나중에 재생",
|
||||
"share": "공유",
|
||||
"shuffle": "셔플",
|
||||
"addToPlaylist": "재생목록 추가",
|
||||
"download": "다운로드",
|
||||
"info": "정보 얻기"
|
||||
},
|
||||
"lists": {
|
||||
"all": "모두",
|
||||
"random": "랜덤",
|
||||
"recentlyAdded": "최근 추가됨",
|
||||
"recentlyPlayed": "최근 재생됨",
|
||||
"mostPlayed": "가장 많이 재생됨",
|
||||
"starred": "즐겨찾기",
|
||||
"topRated": "높은 평가"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "참고",
|
||||
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
|
||||
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
|
||||
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
|
||||
"noPlaylistsAvailable": "사용 가능한 노래 없음",
|
||||
"delete_user_title": "사용자 '%{name}' 삭제",
|
||||
"delete_user_content": "이 사용자와 (재생목록 및 기본 설정 포함된) 모든 데이터를 삭제할까요?",
|
||||
"notifications_blocked": "탐색기 설정에서 이 사이트의 알림을 차단하였음",
|
||||
"notifications_not_available": "이 탐색기는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하지 않음",
|
||||
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음",
|
||||
"lastfmLinkFailure": "Last.fm을 연결할 수 없음",
|
||||
"lastfmUnlinkSuccess": "Last.fm이 연결 해제되었고 스크로블링이 비활성화되었음",
|
||||
"lastfmUnlinkFailure": "Last.fm을 연결 해제할 수 없음",
|
||||
"openIn": {
|
||||
"lastfm": "Last.fm에서 열기",
|
||||
"musicbrainz": "MusicBrainz에서 열기"
|
||||
},
|
||||
"lastfmLink": "더 읽기...",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz가 성공적으로 연결되었고 스크로블링이 사용자로 활성화되었음: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz를 연결할 수 없음: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz가 연결 해제되었고 스크로블링이 비활성화되었음",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz를 연결 해제할 수 없음",
|
||||
"downloadOriginalFormat": "오리지널 형식으로 다운로드",
|
||||
"shareOriginalFormat": "오리지널 형식으로 공유",
|
||||
"shareDialogTitle": "%{resource} '%{name}' 공유",
|
||||
"shareBatchDialogTitle": "1 %{resource} 공유 |||| %{smart_count} %{resource} 공유",
|
||||
"shareSuccess": "URL이 클립보드에 복사됨: %{url}",
|
||||
"shareFailure": "%{url}을 클립보드에 복사하는 중 오류 발생",
|
||||
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
|
||||
"shareCopyToClipboard": "클립보드에 복사: Ctrl+C, Enter"
|
||||
"artist": {
|
||||
"name": "아티스트 |||| 아티스트들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"albumCount": "앨범 수",
|
||||
"songCount": "노래 수",
|
||||
"size": "크기",
|
||||
"playCount": "재생 횟수",
|
||||
"rating": "평가",
|
||||
"genre": "장르",
|
||||
"role": "역할"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "앨범 아티스트 |||| 앨범 아티스트들",
|
||||
"artist": "아티스트 |||| 아티스트들",
|
||||
"composer": "작곡가 |||| 작곡가들",
|
||||
"conductor": "지휘자 |||| 지휘자들",
|
||||
"lyricist": "작사가 |||| 작사가들",
|
||||
"arranger": "편곡가 |||| 편곡가들",
|
||||
"producer": "제작자 |||| 제작자들",
|
||||
"director": "감독 |||| 감독들",
|
||||
"engineer": "엔지니어 |||| 엔지니어들",
|
||||
"mixer": "믹서 |||| 믹서들",
|
||||
"remixer": "리믹서 |||| 리믹서들",
|
||||
"djmixer": "DJ 믹서 |||| DJ 믹서들",
|
||||
"performer": "공연자 |||| 공연자들"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
"settings": "설정",
|
||||
"version": "버전",
|
||||
"theme": "테마",
|
||||
"personal": {
|
||||
"name": "개인 설정",
|
||||
"options": {
|
||||
"theme": "테마",
|
||||
"language": "언어",
|
||||
"defaultView": "기본 보기",
|
||||
"desktop_notifications": "데스크톱 알림",
|
||||
"lastfmScrobbling": "Last.fm으로 스크로블",
|
||||
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
|
||||
"replaygain": "리플레이게인 모드",
|
||||
"preAmp": "리플레이게인 프리앰프 (dB)",
|
||||
"gain": {
|
||||
"none": "비활성화",
|
||||
"album": "앨범 게인 사용",
|
||||
"track": "트랙 게인 사용"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "앨범",
|
||||
"about": "정보",
|
||||
"playlists": "재생목록",
|
||||
"sharedPlaylists": "공유된 재생목록"
|
||||
"user": {
|
||||
"name": "사용자 |||| 사용자들",
|
||||
"fields": {
|
||||
"userName": "사용자이름",
|
||||
"isAdmin": "관리자",
|
||||
"lastLoginAt": "마지막 로그인",
|
||||
"lastAccessAt": "마지막 접속",
|
||||
"updatedAt": "업데이트됨",
|
||||
"name": "이름",
|
||||
"password": "비밀번호",
|
||||
"createdAt": "생성됨",
|
||||
"changePassword": "비밀번호를 변경할까요?",
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"newPassword": "새 비밀번호",
|
||||
"token": "토큰"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "이름 변경 사항은 다음 로그인 시에만 반영됨"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "사용자 생성됨",
|
||||
"updated": "사용자 업데이트됨",
|
||||
"deleted": "사용자 삭제됨"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "대기열 재생",
|
||||
"openText": "열기",
|
||||
"closeText": "닫기",
|
||||
"notContentText": "음악 없음",
|
||||
"clickToPlayText": "재생하려면 클릭",
|
||||
"clickToPauseText": "일시 중지하려면 클릭",
|
||||
"nextTrackText": "다음 트랙",
|
||||
"previousTrackText": "이전 트랙",
|
||||
"reloadText": "다시 로드하기",
|
||||
"volumeText": "볼륨",
|
||||
"toggleLyricText": "가사 전환",
|
||||
"toggleMiniModeText": "최소화",
|
||||
"destroyText": "제거",
|
||||
"downloadText": "다운로드",
|
||||
"removeAudioListsText": "오디오 목록 삭제",
|
||||
"clickToDeleteText": "%{name}을(를) 삭제하려면 클릭",
|
||||
"emptyLyricText": "가사 없음",
|
||||
"playModeText": {
|
||||
"order": "순서대로",
|
||||
"orderLoop": "반복",
|
||||
"singleLoop": "노래 하나 반복",
|
||||
"shufflePlay": "셔플"
|
||||
}
|
||||
"name": "플레이어 |||| 플레이어들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"transcodingId": "트랜스코딩",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"client": "클라이언트",
|
||||
"userName": "사용자이름",
|
||||
"lastSeen": "마지막으로 봤음",
|
||||
"reportRealPath": "실제 경로 보고서",
|
||||
"scrobbleEnabled": "외부 서비스에 스크로블 보내기"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "홈페이지",
|
||||
"source": "소스 코드",
|
||||
"featureRequests": "기능 요청"
|
||||
}
|
||||
"transcoding": {
|
||||
"name": "트랜스코딩 |||| 트랜스코딩",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"targetFormat": "대상 형식",
|
||||
"defaultBitRate": "기본 비트레이트",
|
||||
"command": "명령"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "활동",
|
||||
"totalScanned": "스캔된 전체 폴더",
|
||||
"quickScan": "빠른 스캔",
|
||||
"fullScan": "전체 스캔",
|
||||
"serverUptime": "서버 가동 시간",
|
||||
"serverDown": "오프라인"
|
||||
"playlist": {
|
||||
"name": "재생목록 |||| 재생목록들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"duration": "지속",
|
||||
"ownerName": "소유자",
|
||||
"public": "공개",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"songCount": "노래",
|
||||
"comment": "댓글",
|
||||
"sync": "자동 가져오기",
|
||||
"path": "다음에서 가져오기"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "재생목록 선택:",
|
||||
"addNewPlaylist": "\"%{name}\" 만들기",
|
||||
"export": "내보내기",
|
||||
"makePublic": "공개 만들기",
|
||||
"makePrivate": "비공개 만들기"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "중복된 노래 추가",
|
||||
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 단축키",
|
||||
"hotkeys": {
|
||||
"show_help": "이 도움말 표시",
|
||||
"toggle_menu": "메뉴 사이드바 전환",
|
||||
"toggle_play": "재생 / 일시 중지",
|
||||
"prev_song": "이전 노래",
|
||||
"next_song": "다음 노래",
|
||||
"vol_up": "볼륨 높이기",
|
||||
"vol_down": "볼륨 낮추기",
|
||||
"toggle_love": "이 트랙을 즐겨찾기에 추가",
|
||||
"current_song": "현재 노래로 이동"
|
||||
}
|
||||
"radio": {
|
||||
"name": "라디오 |||| 라디오들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"streamUrl": "스트리밍 URL",
|
||||
"homePageUrl": "홈페이지 URL",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "지금 재생"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "공유 |||| 공유되는 것들",
|
||||
"fields": {
|
||||
"username": "공유됨",
|
||||
"url": "URL",
|
||||
"description": "설명",
|
||||
"downloadable": "다운로드를 허용할까요?",
|
||||
"contents": "컨텐츠",
|
||||
"expiresAt": "만료",
|
||||
"lastVisitedAt": "마지막 방문",
|
||||
"visitCount": "방문 수",
|
||||
"format": "형식",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "누락 파일 |||| 누락 파일들",
|
||||
"empty": "누락 파일 없음",
|
||||
"fields": {
|
||||
"path": "경로",
|
||||
"size": "크기",
|
||||
"updatedAt": "사라짐"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "제거"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "누락된 파일이 제거되었음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Navidrome을 설치해 주셔서 감사합니다!",
|
||||
"welcome2": "관리자를 만들고 시작해 보세요",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"buttonCreateAdmin": "관리자 만들기",
|
||||
"auth_check_error": "계속하려면 로그인하세요",
|
||||
"user_menu": "프로파일",
|
||||
"username": "사용자이름",
|
||||
"password": "비밀번호",
|
||||
"sign_in": "가입",
|
||||
"sign_in_error": "인증에 실패했습니다. 다시 시도하세요",
|
||||
"logout": "로그아웃",
|
||||
"insightsCollectionNote": "Navidrome은 프로젝트 개선을 위해 익명의 사용 데이터를\n수집합니다. 자세한 내용을 알아보고 원치 않으시면 [여기]를\n클릭하여 수신 거부하세요"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "문자와 숫자만 사용하세요",
|
||||
"passwordDoesNotMatch": "비밀번호가 일치하지 않음",
|
||||
"required": "필수 항목임",
|
||||
"minLength": "%{min}자 이하여야 함",
|
||||
"maxLength": "%{max}자 이하여야 함",
|
||||
"minValue": "%{min}자 이상이어야 함",
|
||||
"maxValue": "%{max}자 이하여야 함",
|
||||
"number": "숫자여야 함",
|
||||
"email": "유효한 이메일이어야 함",
|
||||
"oneOf": "다음 중 하나여야 함: %{options}",
|
||||
"regex": "특정 형식(정규식)과 일치해야 함: %{pattern}",
|
||||
"unique": "고유해야 함",
|
||||
"url": "유효한 URL이어야 함"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "필터 추가",
|
||||
"add": "추가",
|
||||
"back": "뒤로 가기",
|
||||
"bulk_actions": "1 item selected |||| %{smart_count} 개 항목이 선택되었음",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "취소",
|
||||
"clear_input_value": "값 지우기",
|
||||
"clone": "복제",
|
||||
"confirm": "확인",
|
||||
"create": "만들기",
|
||||
"delete": "삭제",
|
||||
"edit": "편집",
|
||||
"export": "내보내기",
|
||||
"list": "목록",
|
||||
"refresh": "새로 고침",
|
||||
"remove_filter": "이 필터 제거",
|
||||
"remove": "제거",
|
||||
"save": "저장",
|
||||
"search": "검색",
|
||||
"show": "표시",
|
||||
"sort": "정렬",
|
||||
"undo": "실행 취소",
|
||||
"expand": "확장",
|
||||
"close": "닫기",
|
||||
"open_menu": "메뉴 열기",
|
||||
"close_menu": "메뉴 닫기",
|
||||
"unselect": "선택 해제",
|
||||
"skip": "건너뛰기",
|
||||
"share": "공유",
|
||||
"download": "다운로드"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "예",
|
||||
"false": "아니오"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} 만들기",
|
||||
"dashboard": "대시보드",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "문제가 발생하였음",
|
||||
"list": "%{name}",
|
||||
"loading": "불러오기 중",
|
||||
"not_found": "찾을 수 없음",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "아직 %{name}이(가) 없습니다.",
|
||||
"invite": "추가할까요?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "업로드할 파일을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 파일을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "업로드할 사진을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 사진을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "참조 데이터를 찾을 수 없습니다.",
|
||||
"many_missing": "연관된 참조 중 적어도 하나는 더 이상 사용할 수 없는 것 같습니다.",
|
||||
"single_missing": "연관된 참조는 더 이상 사용할 수 없는 것 같습니다."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "비밀번호 숨기기",
|
||||
"toggle_hidden": "비밀번호 표시"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "정보",
|
||||
"are_you_sure": "확실한가요?",
|
||||
"bulk_delete_content": "이 %{name}을(를) 삭제할까요? |||| 이 %{smart_count} 개의 항목을 삭제할까요?",
|
||||
"bulk_delete_title": "%{name} 삭제 |||| %{smart_count} %{name} 삭제",
|
||||
"delete_content": "이 항목을 삭제할까요?",
|
||||
"delete_title": "%{name} #%{id} 삭제",
|
||||
"details": "상세정보",
|
||||
"error": "클라이언트 오류가 발생하여 요청을 완료할 수 없습니다.",
|
||||
"invalid_form": "양식이 유효하지 않습니다. 오류를 확인하세요",
|
||||
"loading": "페이지가 로드 중입니다. 잠시만 기다려 주세요",
|
||||
"no": "아니오",
|
||||
"not_found": "잘못된 URL을 입력했거나 잘못된 링크를 클릭했습니다.",
|
||||
"yes": "예",
|
||||
"unsaved_changes": "일부 변경 사항이 저장되지 않았습니다. 무시할까요?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "결과를 찾을 수 없음",
|
||||
"no_more_results": "페이지 번호 %{page}이(가) 경계를 벗어났습니다. 이전 페이지를 시도해 보세요.",
|
||||
"page_out_of_boundaries": "페이지 번호 %{page}이(가) 경계를 벗어남",
|
||||
"page_out_from_end": "마지막 페이지 뒤로 갈 수 없음",
|
||||
"page_out_from_begin": "1 페이지 앞으로 갈 수 없음",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
|
||||
"page_rows_per_page": "페이지당 항목:",
|
||||
"next": "다음",
|
||||
"prev": "이전",
|
||||
"skip_nav": "콘텐츠 건너뛰기"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "요소 업데이트됨 |||| %{smart_count} 개 요소 업데이트됨",
|
||||
"created": "요소 생성됨",
|
||||
"deleted": "요소 삭제됨 |||| %{smart_count} 개 요소 삭제됨",
|
||||
"bad_item": "잘못된 요소",
|
||||
"item_doesnt_exist": "요소가 존재하지 않음",
|
||||
"http_error": "서버 통신 오류",
|
||||
"data_provider_error": "dataProvider 오류입니다. 자세한 내용은 콘솔을 확인하세요.",
|
||||
"i18n_error": "지정된 언어에 대한 번역을 로드할 수 없음",
|
||||
"canceled": "작업이 취소됨",
|
||||
"logged_out": "세션이 종료되었습니다. 다시 연결하세요.",
|
||||
"new_version": "새로운 버전이 출시되었습니다! 이 창을 새로 고침하세요."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "표시할 열",
|
||||
"layout": "레이아웃",
|
||||
"grid": "격자",
|
||||
"table": "표"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "참고",
|
||||
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
|
||||
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
|
||||
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
|
||||
"noPlaylistsAvailable": "사용 가능한 노래 없음",
|
||||
"delete_user_title": "사용자 '%{name}' 삭제",
|
||||
"delete_user_content": "이 사용자와 해당 사용자의 모든 데이터(재생 목록 및 환경 설정 포함)를 삭제할까요?",
|
||||
"remove_missing_title": "누락된 파일들 제거",
|
||||
"remove_missing_content": "선택한 누락된 파일을 데이터베이스에서 삭제할까요? 삭제하면 재생 횟수 및 평점을 포함하여 해당 파일에 대한 모든 참조가 영구적으로 삭제됩니다.",
|
||||
"notifications_blocked": "브라우저 설정에서 이 사이트의 알림을 차단하였음",
|
||||
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하고 있지 않음",
|
||||
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음",
|
||||
"lastfmLinkFailure": "Last.fm을 연결 해제할 수 없음",
|
||||
"lastfmUnlinkSuccess": "Last.fm 연결 해제 및 스크로블링 비활성화",
|
||||
"lastfmUnlinkFailure": "Last.fm을 연결 해제할 수 없음",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz가 성공적으로 연결되었고, 다음 사용자로 스크로블링이 활성화되었음: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz를 연결할 수 없음: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz가 연결 해제되었고 스크로블링이 비활성화되었음",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz를 연결 해제할 수 없음",
|
||||
"openIn": {
|
||||
"lastfm": "Last.fm에서 열기",
|
||||
"musicbrainz": "MusicBrainz에서 열기"
|
||||
},
|
||||
"lastfmLink": "더 읽기...",
|
||||
"shareOriginalFormat": "오리지널 형식으로 공유",
|
||||
"shareDialogTitle": "%{resource} '%{name}' 공유",
|
||||
"shareBatchDialogTitle": "1 %{resource} 공유 |||| %{smart_count} %{resource} 공유",
|
||||
"shareCopyToClipboard": "클립보드에 복사: Ctrl+C, Enter",
|
||||
"shareSuccess": "URL이 클립보드에 복사되었음: %{url}",
|
||||
"shareFailure": "URL %{url}을 클립보드에 복사하는 중 오류가 발생하였음",
|
||||
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
|
||||
"downloadOriginalFormat": "오리지널 형식으로 다운로드"
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
"settings": "설정",
|
||||
"version": "버전",
|
||||
"theme": "테마",
|
||||
"personal": {
|
||||
"name": "개인 설정",
|
||||
"options": {
|
||||
"theme": "테마",
|
||||
"language": "언어",
|
||||
"defaultView": "기본 보기",
|
||||
"desktop_notifications": "데스크톱 알림",
|
||||
"lastfmNotConfigured": "Last.fm API 키가 구성되지 않았음",
|
||||
"lastfmScrobbling": "Last.fm으로 스크로블",
|
||||
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
|
||||
"replaygain": "리플레이게인 모드",
|
||||
"preAmp": "리플레이게인 프리앰프 (dB)",
|
||||
"gain": {
|
||||
"none": "비활성화",
|
||||
"album": "앨범 게인 사용",
|
||||
"track": "트랙 게인 사용"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "앨범",
|
||||
"playlists": "재생목록",
|
||||
"sharedPlaylists": "공유된 재생목록",
|
||||
"about": "정보"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "대기열 재생",
|
||||
"openText": "열기",
|
||||
"closeText": "닫기",
|
||||
"notContentText": "음악 없음",
|
||||
"clickToPlayText": "재생하려면 클릭",
|
||||
"clickToPauseText": "일시 중지하려면 클릭",
|
||||
"nextTrackText": "다음 트랙",
|
||||
"previousTrackText": "이전 트랙",
|
||||
"reloadText": "다시 로드하기",
|
||||
"volumeText": "볼륨",
|
||||
"toggleLyricText": "가사 전환",
|
||||
"toggleMiniModeText": "최소화",
|
||||
"destroyText": "제거",
|
||||
"downloadText": "다운로드",
|
||||
"removeAudioListsText": "오디오 목록 삭제",
|
||||
"clickToDeleteText": "%{name}을(를) 삭제하려면 클릭",
|
||||
"emptyLyricText": "가사 없음",
|
||||
"playModeText": {
|
||||
"order": "순서대로",
|
||||
"orderLoop": "반복",
|
||||
"singleLoop": "노래 하나 반복",
|
||||
"shufflePlay": "셔플"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "홈페이지",
|
||||
"source": "소스 코드",
|
||||
"featureRequests": "기능 요청",
|
||||
"lastInsightsCollection": "마지막 인사이트 컬렉션",
|
||||
"insights": {
|
||||
"disabled": "비활성화",
|
||||
"waiting": "대기중"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "활동",
|
||||
"totalScanned": "스캔된 전체 폴더",
|
||||
"quickScan": "빠른 스캔",
|
||||
"fullScan": "전체 스캔",
|
||||
"serverUptime": "서버 가동 시간",
|
||||
"serverDown": "오프라인"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 단축키",
|
||||
"hotkeys": {
|
||||
"show_help": "이 도움말 표시",
|
||||
"toggle_menu": "메뉴 사이드바 전환",
|
||||
"toggle_play": "재생 / 일시 중지",
|
||||
"prev_song": "이전 노래",
|
||||
"next_song": "다음 노래",
|
||||
"current_song": "현재 노래로 이동",
|
||||
"vol_up": "볼륨 높이기",
|
||||
"vol_down": "볼륨 낮추기",
|
||||
"toggle_love": "이 트랙을 즐겨찾기에 추가"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,16 @@
|
||||
"bpm": "BPM",
|
||||
"playDate": "Laatst afgespeeld",
|
||||
"channels": "Kanalen",
|
||||
"createdAt": "Datum toegevoegd"
|
||||
"createdAt": "Datum toegevoegd",
|
||||
"grouping": "Groep",
|
||||
"mood": "Sfeer",
|
||||
"participants": "Extra deelnemers",
|
||||
"tags": "Extra tags",
|
||||
"mappedTags": "Gemapte tags",
|
||||
"rawTags": "Onbewerkte tags",
|
||||
"bitDepth": "Bit diepte",
|
||||
"sampleRate": "Sample waarde",
|
||||
"missing": "Ontbrekend"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
@@ -58,7 +67,15 @@
|
||||
"originalDate": "Origineel",
|
||||
"releaseDate": "Uitgegeven",
|
||||
"releases": "Uitgave |||| Uitgaven",
|
||||
"released": "Uitgegeven"
|
||||
"released": "Uitgegeven",
|
||||
"recordLabel": "Label",
|
||||
"catalogNum": "Catalogus nummer",
|
||||
"releaseType": "Type",
|
||||
"grouping": "Groep",
|
||||
"media": "Media",
|
||||
"mood": "Sfeer",
|
||||
"date": "Opnamedatum",
|
||||
"missing": "Ontbrekend"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Afspelen",
|
||||
@@ -89,7 +106,24 @@
|
||||
"playCount": "Afgespeeld",
|
||||
"rating": "Beoordeling",
|
||||
"genre": "Genre",
|
||||
"size": "Grootte"
|
||||
"size": "Grootte",
|
||||
"role": "Rol",
|
||||
"missing": "Ontbrekend"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Album artiest |||| Album artiesten",
|
||||
"artist": "Artiest |||| Artiesten",
|
||||
"composer": "Componist |||| Componisten",
|
||||
"conductor": "Dirigent |||| Dirigenten",
|
||||
"lyricist": "Tekstschrijver |||| Tekstschrijvers",
|
||||
"arranger": "Arrangeur |||| Arrangeurs",
|
||||
"producer": "Producent |||| Producenten",
|
||||
"director": "Regisseur |||| Regisseurs",
|
||||
"engineer": "Opnametechnicus |||| Opnametechnici",
|
||||
"mixer": "Mixer |||| Mixers",
|
||||
"remixer": "Remixer |||| Remixers",
|
||||
"djmixer": "DJ Mixer |||| DJ Mixers",
|
||||
"performer": "Performer |||| Performers"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -162,7 +196,8 @@
|
||||
"addNewPlaylist": "Creëer \"%{name}\"",
|
||||
"export": "Exporteer",
|
||||
"makePublic": "Openbaar maken",
|
||||
"makePrivate": "Privé maken"
|
||||
"makePrivate": "Privé maken",
|
||||
"saveQueue": "Bewaar wachtrij als playlist"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Dubbele nummers toevoegen",
|
||||
@@ -198,6 +233,22 @@
|
||||
"createdAt": "Gecreëerd op",
|
||||
"downloadable": "Downloads toestaan?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Ontbrekend bestand |||| Ontbrekende bestanden",
|
||||
"fields": {
|
||||
"path": "Pad",
|
||||
"size": "Grootte",
|
||||
"updatedAt": "Verdwenen op"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Verwijder",
|
||||
"remove_all": "Alles verwijderen"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Ontbrekende bestanden verwijderd"
|
||||
},
|
||||
"empty": "Geen ontbrekende bestanden"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -212,7 +263,8 @@
|
||||
"password": "Wachtwoord",
|
||||
"sign_in": "Inloggen",
|
||||
"sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.",
|
||||
"logout": "Uitloggen"
|
||||
"logout": "Uitloggen",
|
||||
"insightsCollectionNote": "Navidrome verzamelt anonieme gebruiksdata om het project te verbeteren. Klik [hier] voor meer info en de mogelijkheid om te weigeren"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Gebruik alleen letters en cijfers",
|
||||
@@ -374,7 +426,11 @@
|
||||
"shareSuccess": "URL gekopieeerd naar klembord: %{url}",
|
||||
"shareFailure": "Fout bij kopieren URL %{url} naar klembord",
|
||||
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter"
|
||||
"shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Verwijder ontbrekende bestanden",
|
||||
"remove_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
|
||||
"remove_all_missing_title": "Verwijder alle ontbrekende bestanden",
|
||||
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
@@ -396,16 +452,17 @@
|
||||
"none": "Uitgeschakeld",
|
||||
"album": "Gebruik Album Gain",
|
||||
"track": "Gebruik Track Gain"
|
||||
}
|
||||
},
|
||||
"lastfmNotConfigured": "Last.fm API-sleutel is niet geconfigureerd"
|
||||
}
|
||||
},
|
||||
"albumList": "Albums",
|
||||
"about": "Over",
|
||||
"playlists": "Playlists",
|
||||
"sharedPlaylists": "Gedeelde playlists"
|
||||
"playlists": "Afspeellijsten",
|
||||
"sharedPlaylists": "Gedeelde afspeellijsten"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Afspeellijst afspelen",
|
||||
"playListsText": "Wachtrij",
|
||||
"openText": "Openen",
|
||||
"closeText": "Sluiten",
|
||||
"notContentText": "Geen muziek",
|
||||
@@ -433,7 +490,12 @@
|
||||
"links": {
|
||||
"homepage": "Thuispagina",
|
||||
"source": "Broncode",
|
||||
"featureRequests": "Functie verzoeken"
|
||||
"featureRequests": "Functie verzoeken",
|
||||
"lastInsightsCollection": "Laatste inzichten",
|
||||
"insights": {
|
||||
"disabled": "Uitgeschakeld",
|
||||
"waiting": "Wachten"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -442,7 +504,10 @@
|
||||
"quickScan": "Snelle scan",
|
||||
"fullScan": "Volledige scan",
|
||||
"serverUptime": "Server uptime",
|
||||
"serverDown": "Offline"
|
||||
"serverDown": "Offline",
|
||||
"scanType": "Type",
|
||||
"status": "Scan fout",
|
||||
"elapsedTime": "Verlopen tijd"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome sneltoetsen",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"languageName": "Português",
|
||||
"languageName": "Português (Brasil)",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Música |||| Músicas",
|
||||
@@ -18,7 +18,6 @@
|
||||
"size": "Tamanho",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"bitRate": "Bitrate",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"discSubtitle": "Sub-título do disco",
|
||||
"starred": "Favorita",
|
||||
"comment": "Comentário",
|
||||
@@ -33,7 +32,10 @@
|
||||
"participants": "Outros Participantes",
|
||||
"tags": "Outras Tags",
|
||||
"mappedTags": "Tags mapeadas",
|
||||
"rawTags": "Tags originais"
|
||||
"rawTags": "Tags originais",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"sampleRate": "Taxa de amostragem",
|
||||
"missing": "Ausente"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
@@ -57,7 +59,6 @@
|
||||
"genre": "Gênero",
|
||||
"compilation": "Coletânea",
|
||||
"year": "Ano",
|
||||
"date": "Data de Lançamento",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"comment": "Comentário",
|
||||
"rating": "Classificação",
|
||||
@@ -72,7 +73,9 @@
|
||||
"releaseType": "Tipo",
|
||||
"grouping": "Agrupamento",
|
||||
"media": "Mídia",
|
||||
"mood": "Mood"
|
||||
"mood": "Mood",
|
||||
"date": "Data de Lançamento",
|
||||
"missing": "Ausente"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Tocar",
|
||||
@@ -104,7 +107,8 @@
|
||||
"rating": "Classificação",
|
||||
"genre": "Gênero",
|
||||
"size": "Tamanho",
|
||||
"role": "Role"
|
||||
"role": "Role",
|
||||
"missing": "Ausente"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista do Álbum |||| Artistas do Álbum",
|
||||
@@ -192,7 +196,8 @@
|
||||
"addNewPlaylist": "Criar \"%{name}\"",
|
||||
"export": "Exportar",
|
||||
"makePublic": "Pública",
|
||||
"makePrivate": "Pessoal"
|
||||
"makePrivate": "Pessoal",
|
||||
"saveQueue": "Salvar fila em nova Playlist"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Adicionar músicas duplicadas",
|
||||
@@ -231,18 +236,19 @@
|
||||
},
|
||||
"missing": {
|
||||
"name": "Arquivo ausente |||| Arquivos ausentes",
|
||||
"empty": "Nenhum arquivo ausente",
|
||||
"fields": {
|
||||
"path": "Caminho",
|
||||
"size": "Tamanho",
|
||||
"updatedAt": "Desaparecido em"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Remover"
|
||||
"remove": "Remover",
|
||||
"remove_all": "Remover todos"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Arquivo(s) ausente(s) removido(s)"
|
||||
}
|
||||
},
|
||||
"empty": "Nenhum arquivo ausente"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -422,7 +428,9 @@
|
||||
"downloadDialogTitle": "Baixar %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copie para o clipboard: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Remover arquivos ausentes",
|
||||
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
|
||||
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
|
||||
"remove_all_missing_title": "Remover todos os arquivos ausentes",
|
||||
"remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -496,7 +504,10 @@
|
||||
"quickScan": "Scan rápido",
|
||||
"fullScan": "Scan completo",
|
||||
"serverUptime": "Uptime do servidor",
|
||||
"serverDown": "DESCONECTADO"
|
||||
"serverDown": "DESCONECTADO",
|
||||
"scanType": "Tipo",
|
||||
"status": "Erro",
|
||||
"elapsedTime": "Duração"
|
||||
},
|
||||
"help": {
|
||||
"title": "Teclas de atalho",
|
||||
@@ -33,8 +33,9 @@
|
||||
"tags": "Дополнительные теги",
|
||||
"mappedTags": "Сопоставленные теги",
|
||||
"rawTags": "Исходные теги",
|
||||
"bitDepth": "Битовая глубина",
|
||||
"sampleRate": "Частота дискретизации (Гц)"
|
||||
"bitDepth": "Битовая глубина (Bit)",
|
||||
"sampleRate": "Частота дискретизации (Hz)",
|
||||
"missing": "Поле отсутствует"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "В очередь",
|
||||
@@ -73,7 +74,8 @@
|
||||
"grouping": "Группирование",
|
||||
"media": "Медиа",
|
||||
"mood": "Настроение",
|
||||
"date": "Дата записи"
|
||||
"date": "Дата записи",
|
||||
"missing": "Поле отсутствует"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Играть",
|
||||
@@ -105,7 +107,8 @@
|
||||
"rating": "Рейтинг",
|
||||
"genre": "Жанр",
|
||||
"size": "Размер",
|
||||
"role": "Роль"
|
||||
"role": "Роль",
|
||||
"missing": "Поле отсутствует"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Исполнитель альбома |||| Исполнители альбома",
|
||||
@@ -157,7 +160,7 @@
|
||||
"fields": {
|
||||
"name": "Имя",
|
||||
"transcodingId": "Транскодирование",
|
||||
"maxBitRate": "Макс. Битрейт",
|
||||
"maxBitRate": "Макс. битрейт",
|
||||
"client": "Клиент",
|
||||
"userName": "Пользователь",
|
||||
"lastSeen": "Был на сайте",
|
||||
@@ -175,7 +178,7 @@
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Плейлистов |||| Плейлисты",
|
||||
"name": "Плейлист |||| Плейлисты",
|
||||
"fields": {
|
||||
"name": "Название",
|
||||
"duration": "Длительность",
|
||||
@@ -193,7 +196,8 @@
|
||||
"addNewPlaylist": "Создать \"%{name}\"",
|
||||
"export": "Экспорт",
|
||||
"makePublic": "Опубликовать",
|
||||
"makePrivate": "Сделать личным"
|
||||
"makePrivate": "Сделать личным",
|
||||
"saveQueue": "Сохранить очередь в плейлист"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Повторяющиеся треки",
|
||||
@@ -224,7 +228,7 @@
|
||||
"lastVisitedAt": "Последнее посещение",
|
||||
"visitCount": "Посещения",
|
||||
"format": "Формат",
|
||||
"maxBitRate": "Макс. Битрейт",
|
||||
"maxBitRate": "Макс. битрейт",
|
||||
"updatedAt": "Обновлено в",
|
||||
"createdAt": "Создано",
|
||||
"downloadable": "Разрешить загрузку?"
|
||||
@@ -238,7 +242,8 @@
|
||||
"updatedAt": "Исчез"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Удалить"
|
||||
"remove": "Удалить",
|
||||
"remove_all": "Убрать все"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Отсутствующие файлы удалены"
|
||||
@@ -274,7 +279,7 @@
|
||||
"oneOf": "Должно быть одним из: %{options}",
|
||||
"regex": "Должно быть в формате (regexp): %{pattern}",
|
||||
"unique": "Должно быть уникальным",
|
||||
"url": "Должен быть действительным URL адрес"
|
||||
"url": "Должен быть действительный URL"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Фильтр",
|
||||
@@ -291,7 +296,7 @@
|
||||
"export": "Экспорт",
|
||||
"list": "Список",
|
||||
"refresh": "Обновить",
|
||||
"remove_filter": "Убрать фильтр",
|
||||
"remove_filter": "Убрать этот фильтр",
|
||||
"remove": "Удалить",
|
||||
"save": "Сохранить",
|
||||
"search": "Поиск",
|
||||
@@ -382,7 +387,7 @@
|
||||
"i18n_error": "Не удалось загрузить перевод для указанного языка",
|
||||
"canceled": "Операция отменена",
|
||||
"logged_out": "Ваша сессия завершена, попробуйте переподключиться/войти снова",
|
||||
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно"
|
||||
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Отображение столбцов",
|
||||
@@ -423,7 +428,9 @@
|
||||
"downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Удалить отсутствующие файлы",
|
||||
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах."
|
||||
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.",
|
||||
"remove_all_missing_title": "Удалите все отсутствующие файлы",
|
||||
"remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Библиотека",
|
||||
@@ -482,7 +489,7 @@
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Главная",
|
||||
"source": "Код",
|
||||
"source": "Исходный код",
|
||||
"featureRequests": "Предложения",
|
||||
"lastInsightsCollection": "Последний сбор данных",
|
||||
"insights": {
|
||||
@@ -497,7 +504,10 @@
|
||||
"quickScan": "Быстрое сканирование",
|
||||
"fullScan": "Полное сканирование",
|
||||
"serverUptime": "Время работы сервера",
|
||||
"serverDown": "Оффлайн"
|
||||
"serverDown": "Оффлайн",
|
||||
"scanType": "Тип",
|
||||
"status": "Ошибка сканирования",
|
||||
"elapsedTime": "Прошедшее время"
|
||||
},
|
||||
"help": {
|
||||
"title": "Горячие клавиши Navidrome",
|
||||
@@ -510,7 +520,7 @@
|
||||
"vol_up": "Увеличить громкость",
|
||||
"vol_down": "Уменьшить громкость",
|
||||
"toggle_love": "Добавить / удалить песню из избранного",
|
||||
"current_song": "Перейти к текущей песне"
|
||||
"current_song": "Перейти к текущему треку"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,16 @@
|
||||
"bpm": "BPM",
|
||||
"playDate": "Senast spelad",
|
||||
"channels": "Channels",
|
||||
"createdAt": "Skapad"
|
||||
"createdAt": "Skapad",
|
||||
"grouping": "Gruppering",
|
||||
"mood": "Stämning",
|
||||
"participants": "Ytterligare medverkande",
|
||||
"tags": "Ytterligare taggar",
|
||||
"mappedTags": "Mappade taggar",
|
||||
"rawTags": "Omodifierade taggar",
|
||||
"bitDepth": "Bitdjup",
|
||||
"sampleRate": "Samplingsfrekvens",
|
||||
"missing": "Saknade"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lägg till i kön",
|
||||
@@ -58,7 +67,15 @@
|
||||
"originalDate": "Originaldatum",
|
||||
"releaseDate": "Utgivningsdatum",
|
||||
"releases": "Utgåva |||| Utgåvor",
|
||||
"released": "Utgiven"
|
||||
"released": "Utgiven",
|
||||
"recordLabel": "Skivbolag",
|
||||
"catalogNum": "Katalognummer",
|
||||
"releaseType": "Typ",
|
||||
"grouping": "Gruppering",
|
||||
"media": "Media",
|
||||
"mood": "Stämning",
|
||||
"date": "Inspelningsdatum",
|
||||
"missing": "Saknade"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Spela",
|
||||
@@ -89,7 +106,24 @@
|
||||
"playCount": "Spelningar",
|
||||
"rating": "Betyg",
|
||||
"genre": "Genre",
|
||||
"size": "Storlek"
|
||||
"size": "Storlek",
|
||||
"role": "Roll",
|
||||
"missing": "Saknade"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albumartist |||| Albumartister",
|
||||
"artist": "Artist |||| Artister",
|
||||
"composer": "Kompositör |||| Kompositörer",
|
||||
"conductor": "Dirigent |||| Dirigenter",
|
||||
"lyricist": "Textförfattare |||| Textförfattare",
|
||||
"arranger": "Arrangör |||| Arrangörer",
|
||||
"producer": "Producent |||| Producenter",
|
||||
"director": "Inspelningsledare |||| Inspelningsledare",
|
||||
"engineer": "Ljudtekniker |||| Ljudtekniker",
|
||||
"mixer": "Mixare |||| Mixare",
|
||||
"remixer": "Remixare |||| Remixare",
|
||||
"djmixer": "DJ-mixare |||| DJ-mixare",
|
||||
"performer": "Utövande artist |||| Utövande artister"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -162,7 +196,8 @@
|
||||
"addNewPlaylist": "Skapa \"%{name}\"",
|
||||
"export": "Exportera",
|
||||
"makePublic": "Gör offentlig",
|
||||
"makePrivate": "Gör privat"
|
||||
"makePrivate": "Gör privat",
|
||||
"saveQueue": "Spara kö till spellista"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Lägg till dubletter",
|
||||
@@ -198,6 +233,22 @@
|
||||
"createdAt": "Skapad",
|
||||
"downloadable": "Tillåt nedladdning?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Saknad fil |||| Saknade filer",
|
||||
"fields": {
|
||||
"path": "Sökväg",
|
||||
"size": "Storlek",
|
||||
"updatedAt": "Försvann"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Radera",
|
||||
"remove_all": "Radera alla"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Saknade fil(er) borttagna"
|
||||
},
|
||||
"empty": "Inga saknade filer"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -375,7 +426,11 @@
|
||||
"shareSuccess": "URL kopierades till urklipp: %{url}",
|
||||
"shareFailure": "Fel vid kopiering av URL %{url} till urklipp",
|
||||
"downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter"
|
||||
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Ta bort saknade filer",
|
||||
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
|
||||
"remove_all_missing_title": "Ta bort alla saknade filer",
|
||||
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
@@ -449,7 +504,10 @@
|
||||
"quickScan": "Snabbscan",
|
||||
"fullScan": "Komplett scan",
|
||||
"serverUptime": "Serverdrifttid",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Typ",
|
||||
"status": "Fel vid scanning",
|
||||
"elapsedTime": "Spelad tid"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome kortkommandon",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"mappedTags": "Eşlenen etiketler",
|
||||
"rawTags": "Ham etiketler",
|
||||
"bitDepth": "Bit derinliği",
|
||||
"sampleRate": "Örnekleme Oranı"
|
||||
"sampleRate": "Örnekleme Oranı",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Oynatma Sırasına Ekle",
|
||||
@@ -73,7 +74,8 @@
|
||||
"grouping": "Gruplama",
|
||||
"media": "Medya",
|
||||
"mood": "Mod",
|
||||
"date": "Kayıt Tarihi"
|
||||
"date": "Kayıt Tarihi",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Oynat",
|
||||
@@ -105,7 +107,8 @@
|
||||
"rating": "Derecelendirme",
|
||||
"genre": "Tür",
|
||||
"size": "Boyut",
|
||||
"role": "Rol"
|
||||
"role": "Rol",
|
||||
"missing": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albüm Sanatçısı |||| Albüm Sanatçısı",
|
||||
@@ -193,7 +196,8 @@
|
||||
"addNewPlaylist": "Oluştur \"%{name}\"",
|
||||
"export": "Aktar",
|
||||
"makePublic": "Herkese Açık Yap",
|
||||
"makePrivate": "Özel Yap"
|
||||
"makePrivate": "Özel Yap",
|
||||
"saveQueue": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Yinelenen şarkıları ekle",
|
||||
@@ -238,7 +242,8 @@
|
||||
"updatedAt": "Kaybolma"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Kaldır"
|
||||
"remove": "Kaldır",
|
||||
"remove_all": "Tümünü Kaldır"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Eksik dosya(lar) kaldırıldı"
|
||||
@@ -423,7 +428,9 @@
|
||||
"downloadDialogTitle": "%{resource}: '%{name}' (%{size}) dosyasını indirin",
|
||||
"shareCopyToClipboard": "Panoya kopyala: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Eksik dosyaları kaldır",
|
||||
"remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır."
|
||||
"remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır.",
|
||||
"remove_all_missing_title": "Tüm eksik dosyaları kaldırın",
|
||||
"remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Kütüphane",
|
||||
@@ -497,7 +504,10 @@
|
||||
"quickScan": "Hızlı Tarama",
|
||||
"fullScan": "Tam Tarama",
|
||||
"serverUptime": "Sunucu Çalışma Süresi",
|
||||
"serverDown": "ÇEVRİMDIŞI"
|
||||
"serverDown": "ÇEVRİMDIŞI",
|
||||
"scanType": "Tür",
|
||||
"status": "Tarama Hatası",
|
||||
"elapsedTime": "Geçen Süre"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Kısayolları",
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
"participants": "Додаткові вчасники",
|
||||
"tags": "Додаткові теги",
|
||||
"mappedTags": "Зіставлені теги",
|
||||
"rawTags": "Вихідні теги"
|
||||
"rawTags": "Вихідні теги",
|
||||
"bitDepth": "Глибина розрядності",
|
||||
"sampleRate": "Частота дискретизації",
|
||||
"missing": "Поле відсутнє"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Прослухати пізніше",
|
||||
@@ -70,7 +73,9 @@
|
||||
"releaseType": "Тип",
|
||||
"grouping": "Групування",
|
||||
"media": "Медіа",
|
||||
"mood": "Настрій"
|
||||
"mood": "Настрій",
|
||||
"date": "Дата запису",
|
||||
"missing": "Поле відсутнє"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Прослухати",
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Рейтинг",
|
||||
"genre": "Жанр",
|
||||
"size": "Розмір",
|
||||
"role": "Роль"
|
||||
"role": "Роль",
|
||||
"missing": "Поле відсутнє"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Виконавець альбому |||| Виконавці альбому",
|
||||
@@ -190,7 +196,8 @@
|
||||
"addNewPlaylist": "Створити \"%{name}\"",
|
||||
"export": "Експортувати",
|
||||
"makePublic": "Зробити публічним",
|
||||
"makePrivate": "Зробити приватним"
|
||||
"makePrivate": "Зробити приватним",
|
||||
"saveQueue": "Зберегти чергу до плейлиста"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Додати повторювані пісні",
|
||||
@@ -235,11 +242,13 @@
|
||||
"updatedAt": "Зник"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Видалити"
|
||||
"remove": "Видалити",
|
||||
"remove_all": "Вилучити всі"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Видалено зниклі файл(и)"
|
||||
}
|
||||
},
|
||||
"empty": "Немає відсутніх файлів"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -419,7 +428,9 @@
|
||||
"downloadDialogTitle": "Завантаження %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Скопіювати в буфер: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Видалити зниклі файли",
|
||||
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги."
|
||||
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги.",
|
||||
"remove_all_missing_title": "Видалити всі відсутні файли",
|
||||
"remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Бібліотека",
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Швидке сканування",
|
||||
"fullScan": "Повне сканування",
|
||||
"serverUptime": "Час роботи",
|
||||
"serverDown": "Оффлайн"
|
||||
"serverDown": "Оффлайн",
|
||||
"scanType": "Тип",
|
||||
"status": "Помилка сканування",
|
||||
"elapsedTime": "Пройдений час"
|
||||
},
|
||||
"help": {
|
||||
"title": "Гарячі клавіші Navidrome",
|
||||
|
||||
@@ -108,7 +108,7 @@ main:
|
||||
bpm:
|
||||
aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ]
|
||||
lyrics:
|
||||
aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics ]
|
||||
aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics, unsyncedlyrics ]
|
||||
maxLength: 32768
|
||||
type: pair # ex: lyrics:eng, lyrics:xxx
|
||||
comment:
|
||||
|
||||
466
scanner/README.md
Normal file
466
scanner/README.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# Navidrome Scanner: Technical Overview
|
||||
|
||||
This document provides a comprehensive technical explanation of Navidrome's music library scanner system.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The Navidrome scanner is built on a multi-phase pipeline architecture designed for efficient processing of music files. It systematically traverses file system directories, processes metadata, and maintains a database representation of the music library. A key performance feature is that some phases run sequentially while others execute in parallel.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "Scanner Execution Flow"
|
||||
Controller[Scanner Controller] --> Scanner[Scanner Implementation]
|
||||
|
||||
Scanner --> Phase1[Phase 1: Folders Scan]
|
||||
Phase1 --> Phase2[Phase 2: Missing Tracks]
|
||||
|
||||
Phase2 --> ParallelPhases
|
||||
|
||||
subgraph ParallelPhases["Parallel Execution"]
|
||||
Phase3[Phase 3: Refresh Albums]
|
||||
Phase4[Phase 4: Playlist Import]
|
||||
end
|
||||
|
||||
ParallelPhases --> FinalSteps[Final Steps: GC + Stats]
|
||||
end
|
||||
|
||||
%% Triggers that can initiate a scan
|
||||
FileChanges[File System Changes] -->|Detected by| Watcher[Filesystem Watcher]
|
||||
Watcher -->|Triggers| Controller
|
||||
|
||||
ScheduledJob[Scheduled Job] -->|Based on Scanner.Schedule| Controller
|
||||
ServerStartup[Server Startup] -->|If Scanner.ScanOnStartup=true| Controller
|
||||
ManualTrigger[Manual Scan via UI/API] -->|Admin user action| Controller
|
||||
CLICommand[Command Line: navidrome scan] -->|Direct invocation| Controller
|
||||
PIDChange[PID Configuration Change] -->|Forces full scan| Controller
|
||||
DBMigration[Database Migration] -->|May require full scan| Controller
|
||||
|
||||
Scanner -.->|Alternative| External[External Scanner Process]
|
||||
```
|
||||
|
||||
The execution flow shows that Phases 1 and 2 run sequentially, while Phases 3 and 4 execute in parallel to maximize performance before the final processing steps.
|
||||
|
||||
## Core Components
|
||||
|
||||
### Scanner Controller (`controller.go`)
|
||||
|
||||
This is the entry point for all scanning operations. It provides:
|
||||
|
||||
- Public API for initiating scans and checking scan status
|
||||
- Event broadcasting to notify clients about scan progress
|
||||
- Serialization of scan operations (prevents concurrent scans)
|
||||
- Progress tracking and monitoring
|
||||
- Error collection and reporting
|
||||
|
||||
```go
|
||||
type Scanner interface {
|
||||
// ScanAll starts a full scan of the music library. This is a blocking operation.
|
||||
ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
|
||||
Status(context.Context) (*StatusInfo, error)
|
||||
}
|
||||
```
|
||||
|
||||
### Scanner Implementation (`scanner.go`)
|
||||
|
||||
The primary implementation that orchestrates the four-phase scanning pipeline. Each phase follows the Phase interface pattern:
|
||||
|
||||
```go
|
||||
type phase[T any] interface {
|
||||
producer() ppl.Producer[T]
|
||||
stages() []ppl.Stage[T]
|
||||
finalize(error) error
|
||||
description() string
|
||||
}
|
||||
```
|
||||
|
||||
This design enables:
|
||||
- Type-safe pipeline construction with generics
|
||||
- Modular phase implementation
|
||||
- Separation of concerns
|
||||
- Easy measurement of performance
|
||||
|
||||
### External Scanner (`external.go`)
|
||||
|
||||
The External Scanner is a specialized implementation that offloads the scanning process to a separate subprocess. This is specifically designed to address memory management challenges in long-running Navidrome instances.
|
||||
|
||||
```go
|
||||
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
|
||||
// memory leaks or retention in the main process, as the scanner can consume a lot of memory. The
|
||||
// external process will be spawned with the same executable as the current process, and will run
|
||||
// the "scan" command with the "--subprocess" flag.
|
||||
//
|
||||
// The external process will send progress updates to the main process through its STDOUT, and the main
|
||||
// process will forward them to the caller.
|
||||
```
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MP as Main Process
|
||||
participant ES as External Scanner
|
||||
participant SP as Subprocess (navidrome scan --subprocess)
|
||||
participant FS as File System
|
||||
participant DB as Database
|
||||
|
||||
Note over MP: DevExternalScanner=true
|
||||
MP->>ES: ScanAll(ctx, fullScan)
|
||||
activate ES
|
||||
|
||||
ES->>ES: Locate executable path
|
||||
ES->>SP: Start subprocess with args:<br>scan --subprocess --configfile ... etc.
|
||||
activate SP
|
||||
|
||||
Note over ES,SP: Create pipe for communication
|
||||
|
||||
par Subprocess executes scan
|
||||
SP->>FS: Read files & metadata
|
||||
SP->>DB: Update database
|
||||
and Main process monitors progress
|
||||
loop For each progress update
|
||||
SP->>ES: Send encoded progress info via stdout pipe
|
||||
ES->>MP: Forward progress info
|
||||
end
|
||||
end
|
||||
|
||||
SP-->>ES: Subprocess completes (success/error)
|
||||
deactivate SP
|
||||
ES-->>MP: Return aggregated warnings/errors
|
||||
deactivate ES
|
||||
```
|
||||
|
||||
Technical details:
|
||||
|
||||
1. **Process Isolation**
|
||||
- Spawns a separate process using the same executable
|
||||
- Uses the `--subprocess` flag to indicate it's running as a child process
|
||||
- Preserves configuration by passing required flags (`--configfile`, `--datafolder`, etc.)
|
||||
|
||||
2. **Inter-Process Communication**
|
||||
- Uses a pipe for bidirectional communication
|
||||
- Encodes/decodes progress updates using Go's `gob` encoding for efficient binary transfer
|
||||
- Properly handles process termination and error propagation
|
||||
|
||||
3. **Memory Management Benefits**
|
||||
- Scanning operations can be memory-intensive, especially with large music libraries
|
||||
- Memory leaks or excessive allocations are automatically cleaned up when the process terminates
|
||||
- Main Navidrome process remains stable even if scanner encounters memory-related issues
|
||||
|
||||
4. **Error Handling**
|
||||
- Detects non-zero exit codes from the subprocess
|
||||
- Propagates error messages back to the main process
|
||||
- Ensures resources are properly cleaned up, even in error conditions
|
||||
|
||||
## Scanning Process Flow
|
||||
|
||||
### Phase 1: Folder Scan (`phase_1_folders.go`)
|
||||
|
||||
This phase handles the initial traversal and media file processing.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 1] --> B{Full Scan?}
|
||||
B -- Yes --> C[Scan All Folders]
|
||||
B -- No --> D[Scan Modified Folders]
|
||||
C --> E[Read File Metadata]
|
||||
D --> E
|
||||
E --> F[Create Artists]
|
||||
E --> G[Create Albums]
|
||||
F --> H[Save to Database]
|
||||
G --> H
|
||||
H --> I[Mark Missing Folders]
|
||||
I --> J[End Phase 1]
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Folder Traversal**
|
||||
- Uses `walkDirTree` to traverse the directory structure
|
||||
- Handles symbolic links and hidden files
|
||||
- Processes `.ndignore` files for exclusions
|
||||
- Maps files to appropriate types (audio, image, playlist)
|
||||
|
||||
2. **Metadata Extraction**
|
||||
- Processes files in batches (defined by `filesBatchSize = 200`)
|
||||
- Extracts metadata using the configured storage backend
|
||||
- Converts raw metadata to `MediaFile` objects
|
||||
- Collects and normalizes tag information
|
||||
|
||||
3. **Album and Artist Creation**
|
||||
- Groups tracks by album ID
|
||||
- Creates album records from track metadata
|
||||
- Handles album ID changes by tracking previous IDs
|
||||
- Creates artist records from track participants
|
||||
|
||||
4. **Database Persistence**
|
||||
- Uses transactions for atomic updates
|
||||
- Preserves album annotations across ID changes
|
||||
- Updates library-artist mappings
|
||||
- Marks missing tracks for later processing
|
||||
- Pre-caches artwork for performance
|
||||
|
||||
### Phase 2: Missing Tracks Processing (`phase_2_missing_tracks.go`)
|
||||
|
||||
This phase identifies tracks that have moved or been deleted.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 2] --> B[Load Libraries]
|
||||
B --> C[Get Missing and Matching Tracks]
|
||||
C --> D[Group by PID]
|
||||
D --> E{Match Type?}
|
||||
E -- Exact --> F[Update Path]
|
||||
E -- Same PID --> G[Update If Only One]
|
||||
E -- Equivalent --> H[Update If No Better Match]
|
||||
F --> I[End Phase 2]
|
||||
G --> I
|
||||
H --> I
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Track Identification Strategy**
|
||||
- Uses persistent identifiers (PIDs) to track tracks across scans
|
||||
- Loads missing tracks and potential matches from the database
|
||||
- Groups tracks by PID to limit comparison scope
|
||||
|
||||
2. **Match Analysis**
|
||||
- Applies three levels of matching criteria:
|
||||
- Exact match (full metadata equivalence)
|
||||
- Single match for a PID
|
||||
- Equivalent match (same base path or similar metadata)
|
||||
- Prioritizes matches in order of confidence
|
||||
|
||||
3. **Database Update Strategy**
|
||||
- Preserves the original track ID
|
||||
- Updates the path to the new location
|
||||
- Deletes the duplicate entry
|
||||
- Uses transactions to ensure atomicity
|
||||
|
||||
### Phase 3: Album Refresh (`phase_3_refresh_albums.go`)
|
||||
|
||||
This phase updates album information based on the latest track metadata.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 3] --> B[Load Touched Albums]
|
||||
B --> C[Filter Unmodified]
|
||||
C --> D{Changes Detected?}
|
||||
D -- Yes --> E[Refresh Album Data]
|
||||
D -- No --> F[Skip]
|
||||
E --> G[Update Database]
|
||||
F --> H[End Phase 3]
|
||||
G --> H
|
||||
H --> I[Refresh Statistics]
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Album Selection Logic**
|
||||
- Loads albums that have been "touched" in previous phases
|
||||
- Uses a producer-consumer pattern for efficient processing
|
||||
- Retrieves all media files for each album for completeness
|
||||
|
||||
2. **Change Detection**
|
||||
- Rebuilds album metadata from associated tracks
|
||||
- Compares album attributes for changes
|
||||
- Skips albums with no media files
|
||||
- Avoids unnecessary database updates
|
||||
|
||||
3. **Statistics Refreshing**
|
||||
- Updates album play counts
|
||||
- Updates artist play counts
|
||||
- Maintains consistency between related entities
|
||||
|
||||
### Phase 4: Playlist Import (`phase_4_playlists.go`)
|
||||
|
||||
This phase imports and updates playlists from the file system.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 4] --> B{AutoImportPlaylists?}
|
||||
B -- No --> C[Skip]
|
||||
B -- Yes --> D{Admin User Exists?}
|
||||
D -- No --> E[Log Warning & Skip]
|
||||
D -- Yes --> F[Load Folders with Playlists]
|
||||
F --> G{For Each Folder}
|
||||
G --> H[Read Directory]
|
||||
H --> I{For Each Playlist}
|
||||
I --> J[Import Playlist]
|
||||
J --> K[Pre-cache Artwork]
|
||||
K --> L[End Phase 4]
|
||||
C --> L
|
||||
E --> L
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Playlist Discovery**
|
||||
- Loads folders known to contain playlists
|
||||
- Focuses on folders that have been touched in previous phases
|
||||
- Handles both playlist formats (M3U, NSP)
|
||||
|
||||
2. **Import Process**
|
||||
- Uses the core.Playlists service for import
|
||||
- Handles both regular and smart playlists
|
||||
- Updates existing playlists when changed
|
||||
- Pre-caches playlist cover art
|
||||
|
||||
3. **Configuration Awareness**
|
||||
- Respects the AutoImportPlaylists setting
|
||||
- Requires an admin user for playlist import
|
||||
- Logs appropriate messages for configuration issues
|
||||
|
||||
## Final Processing Steps
|
||||
|
||||
After the four main phases, several finalization steps occur:
|
||||
|
||||
1. **Garbage Collection**
|
||||
- Removes dangling tracks with no files
|
||||
- Cleans up empty albums
|
||||
- Removes orphaned artists
|
||||
- Deletes orphaned annotations
|
||||
|
||||
2. **Statistics Refresh**
|
||||
- Updates artist song and album counts
|
||||
- Refreshes tag usage statistics
|
||||
- Updates aggregate metrics
|
||||
|
||||
3. **Library Status Update**
|
||||
- Marks scan as completed
|
||||
- Updates last scan timestamp
|
||||
- Stores persistent ID configuration
|
||||
|
||||
4. **Database Optimization**
|
||||
- Performs database maintenance
|
||||
- Optimizes tables and indexes
|
||||
- Reclaims space from deleted records
|
||||
|
||||
## File System Watching
|
||||
|
||||
The watcher system (`watcher.go`) provides real-time monitoring of file system changes:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Watcher] --> B[For Each Library]
|
||||
B --> C[Start Library Watcher]
|
||||
C --> D[Monitor File Events]
|
||||
D --> E{Change Detected?}
|
||||
E -- Yes --> F[Wait for More Changes]
|
||||
F --> G{Time Elapsed?}
|
||||
G -- Yes --> H[Trigger Scan]
|
||||
G -- No --> F
|
||||
H --> I[Wait for Scan Completion]
|
||||
I --> D
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Event Throttling**
|
||||
- Uses a timer to batch changes
|
||||
- Prevents excessive rescanning
|
||||
- Configurable wait period
|
||||
|
||||
2. **Library-specific Watching**
|
||||
- Each library has its own watcher goroutine
|
||||
- Translates paths to library-relative paths
|
||||
- Filters irrelevant changes
|
||||
|
||||
3. **Platform Adaptability**
|
||||
- Uses storage-provided watcher implementation
|
||||
- Supports different notification mechanisms per platform
|
||||
- Graceful fallback when watching is not supported
|
||||
|
||||
## Edge Cases and Optimizations
|
||||
|
||||
### Handling Album ID Changes
|
||||
|
||||
The scanner carefully manages album identity across scans:
|
||||
- Tracks previous album IDs to handle ID generation changes
|
||||
- Preserves annotations when IDs change
|
||||
- Maintains creation timestamps for consistent sorting
|
||||
|
||||
### Detecting Moved Files
|
||||
|
||||
A sophisticated algorithm identifies moved files:
|
||||
1. Groups missing and new files by their Persistent ID
|
||||
2. Applies multiple matching strategies in priority order
|
||||
3. Updates paths rather than creating duplicate entries
|
||||
|
||||
### Resuming Interrupted Scans
|
||||
|
||||
If a scan is interrupted:
|
||||
- The next scan detects this condition
|
||||
- Forces a full scan if the previous one was a full scan
|
||||
- Continues from where it left off for incremental scans
|
||||
|
||||
### Memory Efficiency
|
||||
|
||||
Several strategies minimize memory usage:
|
||||
- Batched file processing (200 files at a time)
|
||||
- External scanner process option
|
||||
- Database-side filtering where possible
|
||||
- Stream processing with pipelines
|
||||
|
||||
### Concurrency Control
|
||||
|
||||
The scanner implements a sophisticated concurrency model to optimize performance:
|
||||
|
||||
1. **Phase-Level Parallelism**:
|
||||
- Phases 1 and 2 run sequentially due to their dependencies
|
||||
- Phases 3 and 4 run in parallel using the `chain.RunParallel()` function
|
||||
- Final steps run sequentially to ensure data consistency
|
||||
|
||||
2. **Within-Phase Concurrency**:
|
||||
- Each phase has configurable concurrency for its stages
|
||||
- For example, `phase_1_folders.go` processes folders concurrently: `ppl.NewStage(p.processFolder, ppl.Name("process folder"), ppl.Concurrency(conf.Server.DevScannerThreads))`
|
||||
- Multiple stages can exist within a phase, each with its own concurrency level
|
||||
|
||||
3. **Pipeline Architecture Benefits**:
|
||||
- Producer-consumer pattern minimizes memory usage
|
||||
- Work is streamed through stages rather than accumulated
|
||||
- Back-pressure is automatically managed
|
||||
|
||||
4. **Thread Safety Mechanisms**:
|
||||
- Atomic counters for statistics gathering
|
||||
- Mutex protection for shared resources
|
||||
- Transactional database operations
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The scanner's behavior can be customized through several configuration settings that directly affect its operation:
|
||||
|
||||
### Core Scanner Options
|
||||
|
||||
| Setting | Description | Default |
|
||||
|-------------------------|------------------------------------------------------------------|----------------|
|
||||
| `Scanner.Enabled` | Whether the automatic scanner is enabled | true |
|
||||
| `Scanner.Schedule` | Cron expression or duration for scheduled scans (e.g., "@daily") | "0" (disabled) |
|
||||
| `Scanner.ScanOnStartup` | Whether to scan when the server starts | true |
|
||||
| `Scanner.WatcherWait` | Delay before triggering scan after file changes detected | 5s |
|
||||
| `Scanner.ArtistJoiner` | String used to join multiple artists in track metadata | " • " |
|
||||
|
||||
### Playlist Processing
|
||||
|
||||
| Setting | Description | Default |
|
||||
|-----------------------------|----------------------------------------------------------|---------|
|
||||
| `PlaylistsPath` | Path(s) to search for playlists (supports glob patterns) | "" |
|
||||
| `AutoImportPlaylists` | Whether to import playlists during scanning | true |
|
||||
|
||||
### Performance Options
|
||||
|
||||
| Setting | Description | Default |
|
||||
|----------------------|-----------------------------------------------------------|---------|
|
||||
| `DevExternalScanner` | Use external process for scanning (reduces memory issues) | true |
|
||||
| `DevScannerThreads` | Number of concurrent processing threads during scanning | 5 |
|
||||
|
||||
### Persistent ID Options
|
||||
|
||||
| Setting | Description | Default |
|
||||
|-------------|---------------------------------------------------------------------|---------------------------------------------------------------------|
|
||||
| `PID.Track` | Format for track persistent IDs (critical for tracking moved files) | "musicbrainz_trackid\|albumid,discnumber,tracknumber,title" |
|
||||
| `PID.Album` | Format for album persistent IDs (affects album grouping) | "musicbrainz_albumid\|albumartistid,album,albumversion,releasedate" |
|
||||
|
||||
These options can be set in the Navidrome configuration file (e.g., `navidrome.toml`) or via environment variables with the `ND_` prefix (e.g., `ND_SCANNER_ENABLED=false`). For environment variables, dots in option names are replaced with underscores.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Navidrome scanner represents a sophisticated system for efficiently managing music libraries. Its phase-based pipeline architecture, careful handling of edge cases, and performance optimizations allow it to handle libraries of significant size while maintaining data integrity and providing a responsive user experience.
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
@@ -37,6 +38,9 @@ type StatusInfo struct {
|
||||
LastScan time.Time
|
||||
Count uint32
|
||||
FolderCount uint32
|
||||
LastError string
|
||||
ScanType string
|
||||
ElapsedTime time.Duration
|
||||
}
|
||||
|
||||
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
|
||||
@@ -94,6 +98,7 @@ type ProgressInfo struct {
|
||||
ChangesDetected bool
|
||||
Warning string
|
||||
Error string
|
||||
ForceUpdate bool
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
@@ -113,20 +118,51 @@ type controller struct {
|
||||
changesDetected bool
|
||||
}
|
||||
|
||||
// getScanInfo retrieves scan status from the database
|
||||
func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) {
|
||||
lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
|
||||
scanType, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
|
||||
startTimeStr, _ := s.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
|
||||
|
||||
if startTimeStr != "" {
|
||||
startTime, err := time.Parse(time.RFC3339, startTimeStr)
|
||||
if err == nil {
|
||||
if running.Load() {
|
||||
elapsed = time.Since(startTime)
|
||||
} else {
|
||||
// If scan is not running, try to get the last scan time for the library
|
||||
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
|
||||
if err == nil {
|
||||
elapsed = lib.LastScanAt.Sub(startTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scanType, elapsed, lastErr
|
||||
}
|
||||
|
||||
func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
|
||||
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting library: %w", err)
|
||||
}
|
||||
|
||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||
|
||||
if running.Load() {
|
||||
status := &StatusInfo{
|
||||
Scanning: true,
|
||||
LastScan: lib.LastScanAt,
|
||||
Count: s.count.Load(),
|
||||
FolderCount: s.folderCount.Load(),
|
||||
LastError: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
count, folderCount, err := s.getCounters(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting library stats: %w", err)
|
||||
@@ -136,6 +172,9 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
|
||||
LastScan: lib.LastScanAt,
|
||||
Count: uint32(count),
|
||||
FolderCount: uint32(folderCount),
|
||||
LastError: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -193,10 +232,14 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
|
||||
if count, folderCount, err := s.getCounters(ctx); err != nil {
|
||||
return scanWarnings, err
|
||||
} else {
|
||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||
s.sendMessage(ctx, &events.ScanStatus{
|
||||
Scanning: false,
|
||||
Count: count,
|
||||
FolderCount: folderCount,
|
||||
Error: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
})
|
||||
}
|
||||
return scanWarnings, scanError
|
||||
@@ -240,12 +283,17 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
|
||||
if p.FileCount > 0 {
|
||||
s.folderCount.Add(1)
|
||||
}
|
||||
|
||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||
status := &events.ScanStatus{
|
||||
Scanning: true,
|
||||
Count: int64(s.count.Load()),
|
||||
FolderCount: int64(s.folderCount.Load()),
|
||||
Error: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
}
|
||||
if s.limiter != nil {
|
||||
if s.limiter != nil && !p.ForceUpdate {
|
||||
s.limiter.Do(func() { s.sendMessage(ctx, status) })
|
||||
} else {
|
||||
s.sendMessage(ctx, status)
|
||||
|
||||
57
scanner/controller_test.go
Normal file
57
scanner/controller_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package scanner_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Controller", func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
var ctrl scanner.Scanner
|
||||
|
||||
Describe("Status", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
db.Init(ctx)
|
||||
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
ds.MockedProperty = &tests.MockedPropertyRepo{}
|
||||
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Name: "lib", Path: "/tmp"})).To(Succeed())
|
||||
})
|
||||
|
||||
It("includes last scan error", func() {
|
||||
Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "boom")).To(Succeed())
|
||||
status, err := ctrl.Status(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(status.LastError).To(Equal("boom"))
|
||||
})
|
||||
|
||||
It("includes scan type and error in status", func() {
|
||||
// Set up test data in property repo
|
||||
Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "test error")).To(Succeed())
|
||||
Expect(ds.Property(ctx).Put(consts.LastScanTypeKey, "full")).To(Succeed())
|
||||
|
||||
// Get status and verify basic info
|
||||
status, err := ctrl.Status(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(status.LastError).To(Equal("test error"))
|
||||
Expect(status.ScanType).To(Equal("full"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
@@ -182,7 +184,35 @@ func (p *phaseMissingTracks) finalize(err error) error {
|
||||
if matched > 0 {
|
||||
log.Info(p.ctx, "Scanner: Found moved files", "total", matched, err)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if we should purge missing items
|
||||
if conf.Server.Scanner.PurgeMissing == consts.PurgeMissingAlways || (conf.Server.Scanner.PurgeMissing == consts.PurgeMissingFull && p.state.fullScan) {
|
||||
if err = p.purgeMissing(); err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error purging missing items", err)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *phaseMissingTracks) purgeMissing() error {
|
||||
deletedCount, err := p.ds.MediaFile(p.ctx).DeleteAllMissing()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deleting missing files: %w", err)
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
log.Info(p.ctx, "Scanner: Purged missing items from the database", "mediaFiles", deletedCount)
|
||||
// Set changesDetected to true so that garbage collection will run at the end of the scan process
|
||||
p.state.changesDetected.Store(true)
|
||||
} else {
|
||||
log.Debug(p.ctx, "Scanner: No missing items to purge")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ phase[*missingTracks] = (*phaseMissingTracks)(nil)
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -222,4 +224,66 @@ var _ = Describe("phaseMissingTracks", func() {
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("finalize", func() {
|
||||
It("should return nil if no error", func() {
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should return the error if provided", func() {
|
||||
err := phase.finalize(context.DeadlineExceeded)
|
||||
Expect(err).To(Equal(context.DeadlineExceeded))
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
|
||||
When("PurgeMissing is 'always'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways
|
||||
mr.CountAllValue = 3
|
||||
mr.DeleteAllMissingValue = 3
|
||||
})
|
||||
It("should purge missing files", func() {
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("PurgeMissing is 'full'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull
|
||||
mr.CountAllValue = 2
|
||||
mr.DeleteAllMissingValue = 2
|
||||
})
|
||||
It("should not purge missing files if not a full scan", func() {
|
||||
state.fullScan = false
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
It("should purge missing files if full scan", func() {
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
state.fullScan = true
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("PurgeMissing is 'never'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||
mr.CountAllValue = 1
|
||||
mr.DeleteAllMissingValue = 1
|
||||
})
|
||||
It("should not purge missing files", func() {
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,12 +57,21 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
|
||||
startTime := time.Now()
|
||||
log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs))
|
||||
|
||||
// Store scan type and start time
|
||||
scanType := "quick"
|
||||
if state.fullScan {
|
||||
scanType = "full"
|
||||
}
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType)
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339))
|
||||
|
||||
// if there was a full scan in progress, force a full scan
|
||||
if !state.fullScan {
|
||||
for _, lib := range libs {
|
||||
if lib.FullScanInProgress {
|
||||
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
|
||||
state.fullScan = true
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -100,11 +109,14 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err)
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error())
|
||||
state.sendError(err)
|
||||
s.metrics.WriteAfterScanMetrics(ctx, false)
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, "")
|
||||
|
||||
if state.changesDetected.Load() {
|
||||
state.sendProgress(&ProgressInfo{ChangesDetected: true})
|
||||
}
|
||||
@@ -115,6 +127,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
|
||||
|
||||
func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error {
|
||||
return func() error {
|
||||
state.sendProgress(&ProgressInfo{ForceUpdate: true})
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
if state.changesDetected.Load() {
|
||||
start := time.Now()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user