Compare commits

..

9 Commits

Author SHA1 Message Date
Deluan
2a81ec9b5a docs: update plugin system design with security enhancements
Added local network access control flag, plugin verification system, and capabilities declaration to the HLD. Updated implementation plan to remove UserPreference functionality from PoC scope while maintaining security features like local network control and hash verification. Added future extensions section outlining potential plugin types beyond metadata agents.
2025-04-13 10:18:28 -04:00
Deluan
4d4625c766 docs: Add table of contents to plugin HLD
Add a two-level TOC with links to all major sections of the document
2025-04-13 00:15:08 -04:00
Deluan
626e5a7bb0 docs: Add plugin directory structure info and implementation plan
- Add section 5.6 describing the plugin directory structure\n- Add new implementation plan document with phased approach\n- Include progress tracking with checkboxes
2025-04-13 00:10:23 -04:00
Deluan
41535b54f5 docs: Add agent system integration to plugin HLD
- Add section 3.7 describing integration with existing agent system\n- Include visualization diagram of plugin-agent architecture\n- Document plugin adapter approach and future evolutions
2025-04-12 23:30:08 -04:00
Deluan
7e835b4557 Update plugin initialization flow to pass configuration during Init() 2025-04-12 22:05:45 -04:00
Deluan
67c4fa2c9d Improve sequence diagram by breaking down Navidrome Core into specific components 2025-04-12 21:38:05 -04:00
Deluan
438fd93d8e Clarify Permission Manager responsibilities in HLD 2025-04-12 21:33:54 -04:00
Deluan
96f446c4a0 Add support for wildcard URL permissions in plugin system 2025-04-12 18:46:27 -04:00
Deluan
449dd53edf Add High-Level Design document for plugin system 2025-04-12 17:19:25 -04:00
57 changed files with 2946 additions and 2874 deletions

110
AGENTS.md
View File

@@ -1,110 +0,0 @@
# Testing Instructions
- **No implementation task is considered complete until it includes thorough, passing tests that cover the new or
changed functionality. All new code must be accompanied by Ginkgo/Gomega tests, and PRs/commits without tests should
be considered incomplete.**
- All Go tests in this project **MUST** be written using the **Ginkgo v2** and **Gomega** frameworks.
- To run all tests, use `make test`.
- To run tests for a specific package, use `make test PKG=./pkgname/...`
- Do not run tests in parallel
- Don't use `--fail-on-pending`
## Mocking Convention
- Always try to use the mocks provided in the `tests` package before creating a new mock implementation.
- Only create a new mock if the required functionality is not covered by the existing mocks in `tests`.
- Never mock a real implementation when testing. Remember: there is no value in testing an interface, only the real implementation.
## Example
Every package that you write tests for, should have a `*_suite_test.go` file, to hook up the Ginkgo test suite. Example:
```
package core
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestCore(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Core Suite")
}
```
Never put a `func Test*` in regular *_test.go files, only in `*_suite_test.go` files.
Refer to existing test suites for examples of proper setup and usage, such as the one defined in @core_suite_test.go
## Exceptions
There should be no exceptions to this rule. If you encounter tests written with the standard `testing` package or other frameworks, they should be refactored to use Ginkgo/Gomega. If you need a new mock, first confirm that it does not already exist in the `tests` package.
### Configuration
You can set config values in the BeforeEach/BeforeAll blocks. If you do so, remember to add `DeferCleanup(configtest.SetupConfig())` to reset the values. Example:
```go
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableDownloads = true
})
```
# Logging System Usage Guide
This project uses a custom logging system built on top of logrus, `log/log.go`. Follow these conventions for all logging:
## Logging API
- Use the provided functions for logging at different levels:
- `Error(...)`, `Warn(...)`, `Info(...)`, `Debug(...)`, `Trace(...)`, `Fatal(...)`
- These functions accept flexible arguments:
- The first argument can be a context (`context.Context`), an HTTP request, or `nil`.
- The next argument is the log message (string or error).
- Additional arguments are key-value pairs (e.g., `"key", value`).
- If the last argument is an error, it is logged under the `error` key.
**Examples:**
```go
log.Error("A message")
log.Error(ctx, "A message with context")
log.Error("Failed to save", "id", 123, err)
log.Info(req, "Request received", "user", userID)
```
## Logging errors
- You don't need to add "err" key when logging an error, it is automatically added.
- Error must always be the last parameter in the log call.
Examples:
```go
log.Error("Failed to save", "id", 123, err) // GOOD
log.Error("Failed to save", "id", 123, "err", err) // BAD
log.Error("Failed to save", err, "id", 123) // BAD
```
## Context and Request Logging
- If a context or HTTP request is passed as the first argument, any logger fields in the context are included in the log entry.
- Use `log.NewContext(ctx, "key", value, ...)` to add fields to a context for logging.
## Log Levels
- Set the global log level with `log.SetLevel(log.LevelInfo)` or `log.SetLevelString("info")`.
- Per-path log levels can be set with `log.SetLogLevels(map[string]string{"path": "level"})`.
- Use `log.IsGreaterOrEqualTo(level)` to check if a log level is enabled for the current code path.
## Source Line Logging
- Enable source file/line logging with `log.SetLogSourceLine(true)`.
## Best Practices
- Always use the logging API, never log directly with logrus or fmt.
- Prefer structured logging (key-value pairs) for important data.
- Use context/request logging for traceability in web handlers.
- For tests, use Ginkgo/Gomega and set up a test logger as in `log/log_test.go`.
## See Also
- `log/log.go` for implementation details
- `log/log_test.go` for usage examples and test patterns

View File

@@ -36,9 +36,8 @@ 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 $(PKG)
go test -tags netgo ./...
.PHONY: test
testrace: ##@Development Run Go tests with race detector
@@ -157,10 +156,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=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; \
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; \
for file in *.zip; do unzip -n $${file}; done )
@echo "Done. Remember to set your MusicFolder to ./music"
.PHONY: get-music

View File

@@ -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, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 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(BeTrue())
Expect(m.HasPicture).To(BeFalse())
})
DescribeTable("Format-Specific tests",
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics 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(Equal(image))
Expect(m.HasPicture).To(BeFalse())
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, true),
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 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),
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),
// 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, true),
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false),
// 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, false),
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", 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, true),
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", 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, true),
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true),
)
// Skip these tests when running as root

View File

@@ -225,25 +225,18 @@ 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;
}

View File

@@ -93,7 +93,6 @@ type configOptions struct {
PID pidOptions
Inspect inspectOptions
Subsonic subsonicOptions
LyricsPriority string
Agents string
LastFM lastfmOptions
@@ -110,6 +109,7 @@ type configOptions struct {
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevEnableBufferedScrobble bool
DevShowArtistPage bool
DevOffsetOptimize int
DevArtworkMaxRequests int
@@ -132,7 +132,6 @@ 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
}
type subsonicOptions struct {
@@ -313,7 +312,6 @@ 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 {
@@ -500,7 +498,6 @@ func init() {
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false)
@@ -531,8 +528,6 @@ func init() {
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devenableprofiler", false)
@@ -540,6 +535,7 @@ func init() {
viper.SetDefault("devautologinusername", "")
viper.SetDefault("devactivitypanel", true)
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
viper.SetDefault("devenablebufferedscrobble", true)
viper.SetDefault("devsidebarplaylists", true)
viper.SetDefault("devshowartistpage", true)
viper.SetDefault("devoffsetoptimize", 50000)

View File

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

View File

@@ -344,10 +344,18 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
func init() {
conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
return lastFMConstructor(ds)
a := lastFMConstructor(ds)
if a != nil {
return a
}
return nil
})
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return lastFMConstructor(ds)
a := lastFMConstructor(ds)
if a != nil {
return a
}
return nil
})
})
}

View File

@@ -1,55 +0,0 @@
package core
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
)
var _ = Describe("common.go", func() {
Describe("userName", func() {
It("returns the username from context", func() {
ctx := request.WithUser(context.Background(), model.User{UserName: "testuser"})
Expect(userName(ctx)).To(Equal("testuser"))
})
It("returns 'UNKNOWN' if no user in context", func() {
ctx := context.Background()
Expect(userName(ctx)).To(Equal("UNKNOWN"))
})
})
Describe("AbsolutePath", func() {
var (
ds *tests.MockDataStore
libId int
path string
)
BeforeEach(func() {
ds = &tests.MockDataStore{}
libId = 1
path = "music/file.mp3"
mockLib := &tests.MockLibraryRepo{}
mockLib.SetData(model.Libraries{{ID: libId, Path: "/library/root"}})
ds.MockedLibrary = mockLib
})
It("returns the absolute path when library exists", func() {
ctx := context.Background()
abs := AbsolutePath(ctx, ds, libId, path)
Expect(abs).To(Equal("/library/root/music/file.mp3"))
})
It("returns the original path if library not found", func() {
ctx := context.Background()
abs := AbsolutePath(ctx, ds, 999, path)
Expect(abs).To(Equal(path))
})
})
})

View File

@@ -1,37 +0,0 @@
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
}

View File

@@ -1,17 +0,0 @@
package lyrics_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestLyrics(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Lyrics Suite")
}

View File

@@ -1,124 +0,0 @@
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())
})
})
})
})

View File

@@ -1,51 +0,0 @@
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
}

View File

@@ -1,112 +0,0 @@
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,
},
}))
})
})
})

View File

@@ -5,6 +5,7 @@ import (
"sort"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -60,7 +61,9 @@ func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
continue
}
enabled = append(enabled, name)
s = newBufferedScrobbler(ds, s, name)
if conf.Server.DevEnableBufferedScrobble {
s = newBufferedScrobbler(ds, s, name)
}
p.scrobblers[name] = s
}
log.Debug("List of scrobblers enabled", "names", enabled)
@@ -180,7 +183,11 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile,
if !s.IsAuthorized(ctx, u.ID) {
continue
}
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
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)
}
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)

View File

@@ -5,6 +5,7 @@ import (
"errors"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
@@ -26,6 +27,9 @@ 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})
@@ -38,7 +42,6 @@ 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",

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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|lang):([^]]+)]`)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset):([^]]+)]`)
)
func (l Lyrics) IsEmpty() bool {
@@ -72,8 +72,6 @@ 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)

View File

@@ -9,9 +9,8 @@ import (
var _ = Describe("ToLyrics", func() {
It("should parse tags with spaces", func() {
num := int64(1551)
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
lyrics, err := ToLyrics("xxx", "[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"))

View File

@@ -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 := Expr(`
query := rawSQL(`
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

View File

@@ -239,7 +239,7 @@ func (r *artistRepository) purgeEmpty() error {
// RefreshPlayCounts updates the play count and last play date annotations for all artists, based
// on the media files associated with them.
func (r *artistRepository) RefreshPlayCounts() (int64, error) {
query := Expr(`
query := rawSQL(`
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,123 +259,76 @@ on conflict (user_id, item_id, item_type) do update
return r.executeSQL(query)
}
// 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.
// RefreshStats updates the stats field for all artists, based on the media files associated with them.
// BFR Maybe filter by "touched" artists?
func (r *artistRepository) RefreshStats() (int64, error) {
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)
`
// 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
),
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 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
),
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 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
),
// 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
-- 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
)
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
-- 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)
}
func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) {

View File

@@ -57,6 +57,14 @@ 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}
}

View File

@@ -136,7 +136,7 @@ func (r *libraryRepository) ScanEnd(id int) error {
return err
}
// https://www.sqlite.org/pragma.html#pragma_optimize
_, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;"))
_, err = r.executeSQL(rawSQL("PRAGMA optimize=0x10012;"))
return err
}

View File

@@ -77,7 +77,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
"title": "order_title",
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"album": "order_album_name, release_date, disc_number, track_number, order_artist_name, title",
"random": "random",
"created_at": "media_file.created_at",
"starred_at": "starred, starred_at",
@@ -87,12 +87,11 @@ 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,
"artists_id": artistFilter,
"id": idFilter("media_file"),
"title": fullTextFilter("media_file"),
"starred": booleanFilter,
"genre_id": tagIDFilter,
"missing": booleanFilter,
}
// Add all album tags as filters
for tag := range model.TagMappings() {
@@ -243,7 +242,7 @@ func (r *mediaFileRepository) MarkMissingByFolder(missing bool, folderIDs ...str
// GetMissingAndMatching returns all mediafiles that are missing and their potential matches (comparing PIDs)
// that were added/updated after the last scan started. The result is ordered by PID.
// It does not need to load bookmarks, annotations and participants, as they are not used by the scanner.
// It does not need to load bookmarks, annotations and participnts, as they are not used by the scanner.
func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
subQ := r.newSelect().Columns("pid").
Where(And{

View File

@@ -60,7 +60,7 @@ where tag.id = updated_values.id;
`
for _, table := range []string{"album", "media_file"} {
start := time.Now()
query := Expr(fmt.Sprintf(template, table))
query := rawSQL(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 {

View File

@@ -18,9 +18,6 @@
"size": "Mida del fitxer",
"updatedAt": "Actualitzat",
"bitRate": "Taxa de bits",
"bitDepth": "Bits",
"sampleRate": "Freqüencia de mostreig",
"channels": "Canals",
"discSubtitle": "Subtítol del disc",
"starred": "Preferit",
"comment": "Comentari",
@@ -28,13 +25,8 @@
"quality": "Qualitat",
"bpm": "tempo",
"playDate": "Darrer resproduït",
"createdAt": "Creat el",
"grouping": "Agrupació",
"mood": "Sentiment",
"participants": "Participants",
"tags": "Etiquetes",
"mappedTags": "Etiquetes assignades",
"rawTags": "Etiquetes sense processar"
"channels": "Canals",
"createdAt": ""
},
"actions": {
"addToQueue": "Reprodueix després",
@@ -54,7 +46,6 @@
"duration": "Durada",
"songCount": "Cançons",
"playCount": "Reproduccions",
"size": "Mida",
"name": "Nom",
"genre": "Gènere",
"compilation": "Compilació",
@@ -62,28 +53,22 @@
"updatedAt": "Actualitzat ",
"comment": "Comentari",
"rating": "Valoració",
"createdAt": "Creat el",
"size": "Mida",
"originalDate": "Original",
"releaseDate": "Publicat",
"releases": "LLançament |||| Llançaments",
"released": "Publicat",
"recordLabel": "Discogràfica",
"catalogNum": "Número de catàleg",
"releaseType": "Tipus de publicació",
"grouping": "Agrupació",
"media": "Mitjà",
"mood": "Sentiment"
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
},
"actions": {
"playAll": "Reprodueix",
"playNext": "Reprodueix la següent",
"addToQueue": "Reprodueix després",
"share": "Compartir",
"shuffle": "Aleatori",
"addToPlaylist": "Afegeix a la llista",
"download": "Descarrega",
"info": "Obtén informació"
"info": "Obtén informació",
"share": ""
},
"lists": {
"all": "Tot",
@@ -100,27 +85,11 @@
"fields": {
"name": "Nom",
"albumCount": "Nombre d'àlbums",
"songCount": "Nombre de cançons",
"size": "Mida",
"songCount": "Compte de cançons",
"playCount": "Reproduccions",
"rating": "Valoració",
"genre": "Gènere",
"role": "Rol"
},
"roles": {
"albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum",
"artist": "Artista |||| Artistes",
"composer": "Compositor |||| Compositors",
"conductor": "Conductor |||| Conductors",
"lyricist": "Lletrista |||| Lletristes",
"arranger": "Arranjador |||| Arranjadors",
"producer": "Productor |||| Productors",
"director": "Director |||| Directors",
"engineer": "Enginyer |||| Enginyers",
"mixer": "Mesclador |||| Mescladors",
"remixer": "Remesclador |||| Remescladors",
"djmixer": "DJ Mesclador |||| DJ Mescladors",
"performer": "Intèrpret |||| Intèrprets"
"size": ""
}
},
"user": {
@@ -129,7 +98,6 @@
"userName": "Nom d'usuari",
"isAdmin": "És admin",
"lastLoginAt": "Última connexió",
"lastAccessAt": "Últim Accés",
"updatedAt": "Actualitzat",
"name": "Nom",
"password": "Contrasenya",
@@ -201,53 +169,36 @@
}
},
"radio": {
"name": "Ràdio |||| Ràdios",
"name": "",
"fields": {
"name": "Nom",
"streamUrl": "URL del flux",
"homePageUrl": "URL principal",
"updatedAt": "Actualitzat",
"createdAt": "Creat"
"name": "",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
},
"actions": {
"playNow": "Reprodueix"
"playNow": ""
}
},
"share": {
"name": "Compartir |||| Compartits",
"name": "",
"fields": {
"username": "Compartit per",
"url": "URL",
"description": "Descripció",
"downloadable": "Permet descarregar?",
"contents": "Continguts",
"expiresAt": "Caduca",
"lastVisitedAt": "Última Visita",
"visitCount": "Visites",
"format": "Format",
"maxBitRate": "Taxa de bits màx.",
"updatedAt": "Actualitzat",
"createdAt": "Creat"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "Fitxer faltant |||| Fitxers Faltants",
"empty": "No falten fitxers",
"fields": {
"path": "Directori",
"size": "Mida",
"updatedAt": "Desaparegut"
},
"actions": {
"remove": "Eliminar"
},
"notifications": {
"removed": "Fitxers faltants eliminats"
}
}
},
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "",
"createdAt": "",
"downloadable": ""
}
}
},
"ra": {
"auth": {
"welcome1": "Gràcies d'haver instal·lat Navidrome!",
@@ -260,30 +211,28 @@
"password": "Contrasenya",
"sign_in": "Inicia sessió",
"sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar",
"logout": "Sortida",
"insightsCollectionNote": "Navidrome recull dades d'us anonimitzades per\najudar a millorar el projecte. Clica [aquí] per a saber-ne\nmés i no participar-hi si no vols"
"logout": "Sortida"
},
"validation": {
"invalidChars": "Si us plau, useu només lletres i nombres",
"invalidChars": "Si us plau, useu solament lletres i nombres",
"passwordDoesNotMatch": "Les contrasenyes no coincideixen",
"required": "Obligatori",
"minLength": "Ha de tenir, si més no, %{min} caràcters",
"maxLength": "Ha de tenir %{max} caràcters o menys",
"minValue": "Ha de ser com a mínim %{min}",
"maxLength": "Ha de tenir %{max} caràcter o menys",
"minValue": "Ha de ser si més no %{min}",
"maxValue": "Ha de ser %{max} o menys",
"number": "Ha de ser un nombre",
"email": "Ha de ser un correu vàlid",
"oneOf": "Ha de ser un de: %{options}",
"regex": "Ha de tenir el format (regexp): %{pattern}",
"unique": "Ha de ser únic",
"url": "Ha de ser una URL vàlida"
"url": ""
},
"action": {
"add_filter": "Afegeix un filtre",
"add": "Afegeix",
"back": "Enrere",
"bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Cancel·la",
"clear_input_value": "Neteja el valor",
"clone": "Clona",
@@ -307,8 +256,9 @@
"close_menu": "Tanca el menú",
"unselect": "Anul·la la selecció",
"skip": "Omet",
"share": "Compartir",
"download": "Descarregar"
"bulk_actions_mobile": "",
"share": "",
"download": ""
},
"boolean": {
"true": "Sí",
@@ -384,7 +334,7 @@
"i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat",
"canceled": "Acció cancel·lada",
"logged_out": "La sessió ha acabat, si us plau reconnecteu",
"new_version": "Hi ha una versió nova disponible! Si us plau actualitzeu aquesta finestra."
"new_version": "Hi ha una versió nova disponible! Si us plau refresqueu aquesta finestra."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Columnes a mostrar",
@@ -401,31 +351,29 @@
"noPlaylistsAvailable": "No n'hi ha cap disponible",
"delete_user_title": "Esborra usuari '%{nom}'",
"delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)",
"remove_missing_title": "Eliminar fitxers faltants",
"remove_missing_content": "Segur que vols eliminar els fitxers faltants seleccionats de la base de dades? Això eliminarà permanentment les referències a ells, incloent-hi el nombre de reproduccions i les valoracions.",
"notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador",
"notifications_not_available": "El navegador no suporta les notificacions o no heu connectat a Navidrome per https",
"lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment",
"lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm",
"lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment",
"lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm",
"listenBrainzLinkSuccess": "Connectat correctament a ListenBrainz i seguiment activat com a: %{user}",
"listenBrainzLinkFailure": "No s'ha pogut connectar a ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz desconnectat i seguiment desactivat",
"listenBrainzUnlinkFailure": "No s'ha pogut desconnectar de ListenBrainz",
"openIn": {
"lastfm": "Obri en Last.fm",
"musicbrainz": "Obri en MusicBrainz"
},
"lastfmLink": "Llegeix més...",
"shareOriginalFormat": "Compartir en format original",
"shareDialogTitle": "Compartir %{resource} '%{name}'",
"shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}",
"shareCopyToClipboard": "Copiar al porta-retalls: Ctrl+C, Enter",
"shareSuccess": "URL copiada al porta-retalls: %{url}",
"shareFailure": "Error copiant URL %{url} al porta-retalls",
"downloadDialogTitle": "Deascarregar %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Descarregar en format original"
"listenBrainzLinkSuccess": "Ha reexit la vinculació amb ListenBrainz i se n'ha activat el seguiment com a usuari: %{user}",
"listenBrainzLinkFailure": "No ha estat possible vincular-se a ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "Desvinculat de ListenBrainz i desactivat el seguiment",
"listenBrainzUnlinkFailure": "No s'ha pogut desvincular de ListenBrainz",
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": ""
},
"menu": {
"library": "Discoteca",
@@ -439,15 +387,14 @@
"language": "Llengua",
"defaultView": "Vista per defecte",
"desktop_notifications": "Notificacions d'escriptori",
"lastfmNotConfigured": "No s'ha configurat l'API de Last.fm",
"lastfmScrobbling": "Activa el seguiment de Last.fm",
"listenBrainzScrobbling": "Activa el seguiment de ListenBrainz",
"replaygain": "Mode ReplayGain",
"preAmp": "PreAmp de ReplayGain (dB)",
"replaygain": "",
"preAmp": "",
"gain": {
"none": "Cap",
"album": "Guany de l'àlbum",
"track": "Guany de la pista"
"none": "",
"album": "",
"track": ""
}
}
},
@@ -485,12 +432,7 @@
"links": {
"homepage": "Inici",
"source": "Codi font",
"featureRequests": "Sol·licitud de funcionalitats",
"lastInsightsCollection": "Última recolecció d'informació",
"insights": {
"disabled": "Desactivada",
"waiting": "Esperant"
}
"featureRequests": "Sol·licitud de funcionalitats"
}
},
"activity": {
@@ -512,7 +454,7 @@
"vol_up": "Apuja el volum",
"vol_down": "Abaixa el volum",
"toggle_love": "Afegeix la pista a favorits",
"current_song": "Anar a la cançó actual"
"current_song": ""
}
}
}
}

View File

@@ -1,518 +1,460 @@
{
"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": "정보 얻기"
}
"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": "다운로드를 허용할까요?"
}
}
},
"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": "높은 평가"
}
},
"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": "공연자 |||| 공연자들"
}
},
"user": {
"name": "사용자 |||| 사용자들",
"fields": {
"userName": "사용자이름",
"isAdmin": "관리자",
"lastLoginAt": "마지막 로그인",
"lastAccessAt": "마지막 접속",
"updatedAt": "업데이트됨",
"name": "이름",
"password": "비밀번호",
"createdAt": "생성됨",
"changePassword": "비밀번호를 변경할까요?",
"currentPassword": "현재 비밀번호",
"newPassword": "새 비밀번호",
"token": "토큰"
},
"helperTexts": {
"name": "이름 변경 사항은 다음 로그인 시에만 반영됨"
},
"notifications": {
"created": "사용자 생성됨",
"updated": "사용자 업데이트됨",
"deleted": "사용자 삭제됨"
},
"message": {
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
}
},
"player": {
"name": "플레이어 |||| 플레이어들",
"fields": {
"name": "이름",
"transcodingId": "트랜스코딩",
"maxBitRate": "최대 비트레이트",
"client": "클라이언트",
"userName": "사용자이름",
"lastSeen": "마지막으로 봤음",
"reportRealPath": "실제 경로 보고서",
"scrobbleEnabled": "외부 서비스에 스크로블 보내기"
}
},
"transcoding": {
"name": "트랜스코딩 |||| 트랜스코딩",
"fields": {
"name": "이름",
"targetFormat": "대상 형식",
"defaultBitRate": "기본 비트레이트",
"command": "명령"
}
},
"playlist": {
"name": "재생목록 |||| 재생목록들",
"fields": {
"name": "이름",
"duration": "지속",
"ownerName": "소유자",
"public": "공개",
"updatedAt": "업데이트됨",
"createdAt": "생성됨",
"songCount": "노래",
"comment": "댓글",
"sync": "자동 가져오기",
"path": "다음에서 가져오기"
},
"actions": {
"selectPlaylist": "재생목록 선택:",
"addNewPlaylist": "\"%{name}\" 만들기",
"export": "내보내기",
"makePublic": "공개 만들기",
"makePrivate": "비공개 만들기"
},
"message": {
"duplicate_song": "중복된 노래 추가",
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
}
},
"radio": {
"name": "라디오 |||| 라디오들",
"fields": {
"name": "이름",
"streamUrl": "스트리밍 URL",
"homePageUrl": "홈페이지 URL",
"updatedAt": "업데이트됨",
"createdAt": "생성됨"
},
"actions": {
"playNow": "지금 재생"
}
},
"share": {
"name": "공유 |||| 공유되는 것들",
"fields": {
"username": "공유됨",
"url": "URL",
"description": "설명",
"downloadable": "다운로드를 허용할까요?",
"contents": "컨텐츠",
"expiresAt": "만료",
"lastVisitedAt": "마지막 방문",
"visitCount": "방문 수",
"format": "형식",
"maxBitRate": "최대 비트레이트",
"updatedAt": "업데이트됨",
"createdAt": "생성됨"
},
"notifications": {},
"actions": {}
},
"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": "비밀번호 표시"
}
"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": "표"
}
},
"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": "일부 변경 사항이 저장되지 않았습니다. 무시할까요?"
"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"
},
"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": {
"menu": {
"library": "라이브러리",
"settings": "설정",
"version": "버전",
"theme": "테마",
"language": "언어",
"defaultView": "기본 보기",
"desktop_notifications": "데스크톱 알림",
"lastfmNotConfigured": "Last.fm API 키가 구성되지 않았음",
"lastfmScrobbling": "Last.fm으로 스크로블",
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
"replaygain": "리플레이게인 모드",
"preAmp": "리플레이게인 프리앰프 (dB)",
"gain": {
"none": "비활성화",
"album": "앨범 게인 사용",
"track": "트랙 게인 사용"
}
}
"personal": {
"name": "개인 설정",
"options": {
"theme": "테마",
"language": "언어",
"defaultView": "기본 보기",
"desktop_notifications": "데스크톱 알림",
"lastfmScrobbling": "Last.fm으로 스크로블",
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
"replaygain": "리플레이게인 모드",
"preAmp": "리플레이게인 프리앰프 (dB)",
"gain": {
"none": "비활성화",
"album": "앨범 게인 사용",
"track": "트랙 게인 사용"
}
}
},
"albumList": "앨범",
"about": "정보",
"playlists": "재생목록",
"sharedPlaylists": "공유된 재생목록"
},
"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": "셔플"
"player": {
"playListsText": "대기열 재생",
"openText": "열기",
"closeText": "닫기",
"notContentText": "음악 없음",
"clickToPlayText": "재생하려면 클릭",
"clickToPauseText": "일시 중지하려면 클릭",
"nextTrackText": "다음 트랙",
"previousTrackText": "이전 트랙",
"reloadText": "다시 로드하기",
"volumeText": "볼륨",
"toggleLyricText": "가사 전환",
"toggleMiniModeText": "최소화",
"destroyText": "제거",
"downloadText": "다운로드",
"removeAudioListsText": "오디오 목록 삭제",
"clickToDeleteText": "%{name}을(를) 삭제하려면 클릭",
"emptyLyricText": "가사 없음",
"playModeText": {
"order": "순서대로",
"orderLoop": "반복",
"singleLoop": "노래 하나 반복",
"shufflePlay": "셔플"
}
},
"about": {
"links": {
"homepage": "홈페이지",
"source": "소스 코드",
"featureRequests": "기능 요청"
}
},
"activity": {
"title": "활동",
"totalScanned": "스캔된 전체 폴더",
"quickScan": "빠른 스캔",
"fullScan": "전체 스캔",
"serverUptime": "서버 가동 시간",
"serverDown": "오프라인"
},
"help": {
"title": "Navidrome 단축키",
"hotkeys": {
"show_help": "이 도움말 표시",
"toggle_menu": "메뉴 사이드바 전환",
"toggle_play": "재생 / 일시 중지",
"prev_song": "이전 노래",
"next_song": "다음 노래",
"vol_up": "볼륨 높이기",
"vol_down": "볼륨 낮추기",
"toggle_love": "이 트랙을 즐겨찾기에 추가",
"current_song": "현재 노래로 이동"
}
}
},
"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": "이 트랙을 즐겨찾기에 추가"
}
}
}

View File

@@ -1,8 +1,8 @@
{
"languageName": "Norsk",
"languageName": "Engelsk",
"resources": {
"song": {
"name": "Sang |||| Sanger",
"name": "Låt |||| Låter",
"fields": {
"albumArtist": "Album Artist",
"duration": "Tid",
@@ -11,165 +11,164 @@
"title": "Tittel",
"artist": "Artist",
"album": "Album",
"path": "Filsti",
"path": "Filbane",
"genre": "Sjanger",
"compilation": "Samlingg",
"compilation": "Samling",
"year": "År",
"size": "Filstørrelse",
"updatedAt": "Oppdatert",
"bitRate": "Bit rate",
"bitDepth": "Bit depth",
"channels": "Kanaler",
"discSubtitle": "Disk Undertittel",
"updatedAt": "Oppdatert kl",
"bitRate": "Bithastighet",
"discSubtitle": "Diskundertekst",
"starred": "Favoritt",
"comment": "Kommentar",
"rating": "Rangering",
"rating": "Vurdering",
"quality": "Kvalitet",
"bpm": "BPM",
"playDate": "Sist Avspilt",
"createdAt": "Lagt til",
"grouping": "Gruppering",
"mood": "Stemning",
"participants": "Ytterlige deltakere",
"tags": "Ytterlige Tags",
"mappedTags": "Kartlagte tags",
"rawTags": "Rå tags"
"playDate": "Sist spilt",
"channels": "Kanaler",
"createdAt": "",
"grouping": "",
"mood": "",
"participants": "",
"tags": "",
"mappedTags": "",
"rawTags": "",
"bitDepth": ""
},
"actions": {
"addToQueue": "Avspill senere",
"playNow": "Avspill nå",
"addToQueue": "Spill Senere",
"playNow": "Leke nå",
"addToPlaylist": "Legg til i spilleliste",
"shuffleAll": "Shuffle Alle",
"download": "Last ned",
"playNext": "Avspill neste",
"info": "Få Info"
"shuffleAll": "Bland alle",
"download": "nedlasting",
"playNext": "Spill Neste",
"info": "Få informasjon"
}
},
"album": {
"name": "Album |||| Album",
"name": "Album",
"fields": {
"albumArtist": "Album Artist",
"artist": "Artist",
"duration": "Tid",
"songCount": "Sanger",
"playCount": "Avspillinger",
"size": "Størrelse",
"name": "Navn",
"genre": "Sjanger",
"compilation": "Samling",
"year": "År",
"date": "Inspillingsdato",
"originalDate": "Original",
"releaseDate": "Utgitt",
"releases": "Utgivelse |||| Utgivelser",
"released": "Utgitt",
"updatedAt": "Oppdatert",
"updatedAt": "Oppdatert kl",
"comment": "Kommentar",
"rating": "Rangering",
"createdAt": "Lagt Til",
"recordLabel": "Plateselskap",
"catalogNum": "Katalognummer",
"releaseType": "Type",
"grouping": "Gruppering",
"media": "Media",
"mood": "Stemning"
"rating": "Vurdering",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": "",
"recordLabel": "",
"catalogNum": "",
"releaseType": "",
"grouping": "",
"media": "",
"mood": ""
},
"actions": {
"playAll": "Avspill",
"playNext": "Avspill Neste",
"addToQueue": "Avspill Senere",
"share": "Del",
"shuffle": "Shuffle",
"playAll": "Spill",
"playNext": "Spill neste",
"addToQueue": "Spille senere",
"shuffle": "Bland",
"addToPlaylist": "Legg til i spilleliste",
"download": "Last ned",
"info": "Få Info"
"download": "nedlasting",
"info": "Få informasjon",
"share": ""
},
"lists": {
"all": "Alle",
"random": "Tilfeldig",
"recentlyAdded": "Nylig lagt til",
"recentlyPlayed": "Nylig Avspilt",
"mostPlayed": "Mest Avspilt",
"recentlyPlayed": "Nylig spilt",
"mostPlayed": "Mest spilte",
"starred": "Favoritter",
"topRated": "Top Rangert"
"topRated": "Topp rangert"
}
},
"artist": {
"name": "Artist |||| Artister",
"fields": {
"name": "Navn",
"albumCount": "Album Antall",
"songCount": "Song Antall",
"size": "Størrelse",
"playCount": "Avspillinger",
"rating": "Rangering",
"albumCount": "Antall album",
"songCount": "Antall sanger",
"playCount": "Spiller",
"rating": "Vurdering",
"genre": "Sjanger",
"role": "Rolle"
"size": "",
"role": ""
},
"roles": {
"albumartist": "Album Artist |||| Album Artister",
"artist": "Artist |||| Artister",
"composer": "Composer |||| Composers",
"conductor": "Conductor |||| Conductors",
"lyricist": "Lyriker |||| Lyriker",
"arranger": "Arranger |||| Arrangers",
"producer": "Produsent |||| Produsenter",
"director": "Director |||| Directors",
"engineer": "Engineer |||| Engineers",
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
"performer": "Performer |||| Performers"
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"producer": "",
"director": "",
"engineer": "",
"mixer": "",
"remixer": "",
"djmixer": "",
"performer": ""
}
},
"user": {
"name": "Bruker |||| Brukere",
"fields": {
"userName": "Brukernavn",
"isAdmin": "Admin",
"lastLoginAt": "Sist Pålogging",
"lastAccessAt": "Sist Tilgang",
"updatedAt": "Oppdatert",
"isAdmin": "er admin",
"lastLoginAt": "Siste pålogging kl",
"updatedAt": "Oppdatert kl",
"name": "Navn",
"password": "Passord",
"createdAt": "Opprettet",
"changePassword": "Bytt Passord?",
"createdAt": "Opprettet kl",
"changePassword": "Bytte Passord",
"currentPassword": "Nåværende Passord",
"newPassword": "Nytt Passord",
"token": "Token"
"token": "Token",
"lastAccessAt": ""
},
"helperTexts": {
"name": "Navnendringer vil ikke være synlig før neste pålogging"
"name": "Endringer i navnet ditt vil kun gjenspeiles ved neste pålogging"
},
"notifications": {
"created": "Bruker opprettet",
"updated": "Bruker oppdatert",
"deleted": "Bruker slettet"
"deleted": "Bruker fjernet"
},
"message": {
"listenBrainzToken": "Fyll inn din ListenBrainz bruker token.",
"clickHereForToken": "Klikk her for å hente din token"
"listenBrainzToken": "Skriv inn ListenBrainz-brukertokenet ditt.",
"clickHereForToken": "Klikk her for å få tokenet ditt"
}
},
"player": {
"name": "Musikkavspiller |||| Musikkavspillere",
"name": "Avspiller |||| Avspillere",
"fields": {
"name": "Navn",
"transcodingId": "Transkoding",
"maxBitRate": "Maks. Bit Rate",
"transcodingId": "Omkoding",
"maxBitRate": "Maks. Bithastighet",
"client": "Klient",
"userName": "Brukernavn",
"lastSeen": "Sist sett",
"reportRealPath": "Rapporter ekte filsti",
"lastSeen": "Sist sett kl",
"reportRealPath": "Rapporter ekte sti",
"scrobbleEnabled": "Send Scrobbles til eksterne tjenester"
}
},
"transcoding": {
"name": "Transkoding |||| Transkodinger",
"name": "Omkoding |||| Omkodinger",
"fields": {
"name": "Navn",
"targetFormat": "Mål Format",
"defaultBitRate": "Default Bit Rate",
"targetFormat": "Målformat",
"defaultBitRate": "Standard bithastighet",
"command": "Kommando"
}
},
@@ -177,137 +176,135 @@
"name": "Spilleliste |||| Spillelister",
"fields": {
"name": "Navn",
"duration": "Lengde",
"ownerName": "Eier",
"duration": "Varighet",
"ownerName": "Eieren",
"public": "Offentlig",
"updatedAt": "Oppdatert",
"createdAt": "Opprettet",
"updatedAt": "Oppdatert kl",
"createdAt": "Opprettet kl",
"songCount": "Sanger",
"comment": "Kommentar",
"sync": "Auto-importer",
"path": "Importer fra"
"sync": "Autoimport",
"path": "Import fra"
},
"actions": {
"selectPlaylist": "Velg en spilleliste:",
"addNewPlaylist": "Opprett \"%{name}\"",
"export": "Eksporter",
"makePublic": "Gjør Offentlig",
"makePrivate": "Gjør Privat"
"export": "Eksport",
"makePublic": "Gjør offentlig",
"makePrivate": "Gjør privat"
},
"message": {
"duplicate_song": "Legg til Duplikater",
"song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?"
"duplicate_song": "Legg til dupliserte sanger",
"song_exist": "Det legges til duplikater i spillelisten. Vil du legge til duplikatene eller hoppe over dem?"
}
},
"radio": {
"name": "Radio |||| Radio",
"name": "",
"fields": {
"name": "Navn",
"streamUrl": "Stream URL",
"homePageUrl": "Hjemmeside URL",
"updatedAt": "Oppdatert",
"createdAt": "Opprettet"
"name": "",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
},
"actions": {
"playNow": "Avspill"
"playNow": ""
}
},
"share": {
"name": "Del |||| Delinger",
"name": "",
"fields": {
"username": "Delt Av",
"url": "URL",
"description": "Beskrivelse",
"downloadable": "Tillat Nedlastinger?",
"contents": "Innhold",
"expiresAt": "Utløper",
"lastVisitedAt": "Sist Besøkt",
"visitCount": "Visninger",
"format": "Format",
"maxBitRate": "Maks. Bit Rate",
"updatedAt": "Oppdatert",
"createdAt": "Opprettet"
},
"notifications": {},
"actions": {}
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "",
"createdAt": "",
"downloadable": ""
}
},
"missing": {
"name": "Manglende Fil|||| Manglende Filer",
"empty": "Ingen Manglende Filer",
"name": "",
"fields": {
"path": "Filsti",
"size": "Størrelse",
"updatedAt": "Ble borte"
"path": "",
"size": "",
"updatedAt": ""
},
"actions": {
"remove": "Fjern"
"remove": ""
},
"notifications": {
"removed": "Manglende fil(er) fjernet"
}
"removed": ""
},
"empty": ""
}
},
"ra": {
"auth": {
"welcome1": "Takk for at du installerte Navidrome!",
"welcome2": "La oss begynne med å lage en admin bruker.",
"welcome2": "Opprett en admin -bruker for å starte",
"confirmPassword": "Bekreft Passord",
"buttonCreateAdmin": "Opprett Admin",
"auth_check_error": "Logg inn for å fortsette",
"auth_check_error": "Vennligst Logg inn for å fortsette",
"user_menu": "Profil",
"username": "Brukernavn",
"password": "Passord",
"sign_in": "Logg inn",
"sign_in_error": "Autentiseringsfeil, vennligst prøv igjen",
"sign_in_error": "Autentisering mislyktes. Prøv på nytt",
"logout": "Logg ut",
"insightsCollectionNote": "Navidrome innhenter anonymisert forbruksdata\nfor å hjelpe og forbedre prosjektet.\nTrykk [her] for å lære mer og for å melde deg av hvis ønskelig."
"insightsCollectionNote": ""
},
"validation": {
"invalidChars": "Det er kun bokstaver og tall som støttes",
"passwordDoesNotMatch": "Passord samstemmer ikke",
"required": "Kreves",
"minLength": "Må være minst %{min} karakterer.",
"maxLength": "Må være %{max} karakterer eller mindre",
"invalidChars": "Bruk bare bokstaver og tall",
"passwordDoesNotMatch": "Passordet er ikke like",
"required": "Obligatorisk",
"minLength": "Må være minst %{min} tegn",
"maxLength": "Må være %{max} tegn eller færre",
"minValue": "Må være minst %{min}",
"maxValue": "Må være %{max} eller mindre",
"number": "Må være et tall",
"email": "Må være en gyldig epost",
"email": "Må være en gyldig e-post",
"oneOf": "Må være en av: %{options}",
"regex": "Må samstemme med et spesifikt format (regexp): %{pattern}",
"unique": "Må være unikt",
"url": "Må være en gyldig URL"
"regex": "Må samsvare med et spesifikt format (regexp): %{pattern}",
"unique": "Må være unik",
"url": ""
},
"action": {
"add_filter": "Legg til filter",
"add": "Legg Til",
"back": "Tilbake",
"bulk_actions": "1 element valgt |||| %{smart_count} elementer valgt",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"add": "Legge til",
"back": "Gå tilbake",
"bulk_actions": "1 element valgt |||| %{smart_count} elementer er valgt",
"cancel": "Avbryt",
"clear_input_value": "Nullstill verdi",
"clear_input_value": "Klar verdi",
"clone": "Klone",
"confirm": "Bekreft",
"create": "Opprett",
"confirm": "Bekrefte",
"create": "Skape",
"delete": "Slett",
"edit": "Rediger",
"export": "Eksporter",
"edit": "Redigere",
"export": "Eksport",
"list": "Liste",
"refresh": "Oppdater",
"refresh": "oppdater",
"remove_filter": "Fjern dette filteret",
"remove": "Fjern",
"remove": "Fjerne",
"save": "Lagre",
"search": "Søk",
"show": "Vis",
"sort": "Sorter",
"sort": "Sortere",
"undo": "Angre",
"expand": "Utvid",
"expand": "Utvide",
"close": "Lukk",
"open_menu": "Åpne meny",
"close_menu": "Lukk meny",
"unselect": "Avvelg",
"open_menu": "Åpne menyen",
"close_menu": "Lukk menyen",
"unselect": "Fjern valget",
"skip": "Hopp over",
"share": "Del",
"download": "Last Ned"
"bulk_actions_mobile": "",
"share": "",
"download": ""
},
"boolean": {
"true": "Ja",
@@ -315,29 +312,29 @@
},
"page": {
"create": "Opprett %{name}",
"dashboard": "Dashboard",
"dashboard": "Dashbord",
"edit": "%{name} #%{id}",
"error": "Noe gikk galt",
"list": "%{name}",
"list": "%{Navn}",
"loading": "Laster",
"not_found": "Ikke Funnet",
"not_found": "Ikke funnet",
"show": "%{name} #%{id}",
"empty": "Ingen %{name} enda.",
"invite": "Ønsker du å legge til en?"
"empty": "Ingen %{name} en.",
"invite": "Vil du legge til en?"
},
"input": {
"file": {
"upload_several": "Dra filer hit for å laste opp, eller klikk for å velge en.",
"upload_single": "Dra en fil hit for å laste opp, eller klikk for å velge den."
"upload_several": "Slipp noen filer for å laste opp, eller klikk for å velge en.",
"upload_single": "Slipp en fil for å laste opp, eller klikk for å velge den."
},
"image": {
"upload_several": "Dra bilder hit for å laste opp, eller klikk for å velge en.",
"upload_single": "Dra et bilde hit for å laste opp, eller klikk for å velge den."
"upload_several": "Slipp noen bilder for å laste opp, eller klikk for å velge ett.",
"upload_single": "Slipp et bilde for å laste opp, eller klikk for å velge det."
},
"references": {
"all_missing": "Finner ikke referansedata.",
"many_missing": "Minst en av de tilhørende referansene ser ikke lenger ut til å være tilgjengelig.",
"single_missing": "Tilhørende referanse ser ikke lenger ut til å være tilgjengelig."
"all_missing": "Kan ikke finne referansedata.",
"many_missing": "Minst én av de tilknyttede referansene ser ikke ut til å være tilgjengelig lenger.",
"single_missing": "Tilknyttet referanse ser ikke lenger ut til å være tilgjengelig."
},
"password": {
"toggle_visible": "Skjul passord",
@@ -349,86 +346,86 @@
"are_you_sure": "Er du sikker?",
"bulk_delete_content": "Er du sikker på at du vil slette denne %{name}? |||| Er du sikker på at du vil slette disse %{smart_count} elementene?",
"bulk_delete_title": "Slett %{name} |||| Slett %{smart_count} %{name}",
"delete_content": "Er du sikker på at du ønsker å slette dette elementet?",
"delete_content": "Er du sikker på at du vil slette dette elementet?",
"delete_title": "Slett %{name} #%{id}",
"details": "Detaljer",
"error": "En klient feil har oppstått og din forespørsel lot seg ikke gjennomføre.",
"invalid_form": "Skjemaet er ikke gyldig. Vennligst se etter feil.",
"loading": "Siden laster, vennligst vent.",
"error": "Det oppstod en klientfeil og forespørselen din kunne ikke fullføres.",
"invalid_form": "Skjemaet er ikke gyldig. Vennligst se etter feil",
"loading": "Siden lastes, bare et øyeblikk",
"no": "Nei",
"not_found": "Enten skrev du feil URL, eller så har du fulgt en dårlig link.",
"not_found": "Enten skrev du inn feil URL, eller så fulgte du en dårlig lenke.",
"yes": "Ja",
"unsaved_changes": "Noen av dine endringer ble ikke lagret. Er du sikker på at du ønsker å ignorere de?"
"unsaved_changes": "Noen av endringene dine ble ikke lagret. Er du sikker på at du vil ignorere dem?"
},
"navigation": {
"no_results": "Ingen resultater",
"no_more_results": "Sidenummeret %{page} er utenfor grensene. Prøv forrige side.",
"page_out_of_boundaries": "Sidenummer %{page} er utenfor grensene",
"page_out_from_end": "Kan ikke være etter siste side",
"page_out_from_begin": "Kan ikke være før side 1",
"no_more_results": "Sidetallet %{page} er utenfor grensene. Prøv forrige side.",
"page_out_of_boundaries": "Sidetall %{page} utenfor grensene",
"page_out_from_end": "Kan ikke etter siste side",
"page_out_from_begin": "Kan ikke før side 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
"page_rows_per_page": "Elementer per side:",
"next": "Neste",
"prev": "Forrige",
"skip_nav": "Hopp til innhold"
"skip_nav": "Hopp til innholdet"
},
"notification": {
"updated": "Element oppdatert |||| %{smart_count} elementer oppdatert",
"updated": "Element oppdatert |||| %{smart_count} elementer er oppdatert",
"created": "Element opprettet",
"deleted": "Element slettet |||| %{smart_count} elementer slettet",
"bad_item": "Feil element",
"item_doesnt_exist": "Element eksisterer ikke",
"http_error": "Kommunikasjonsfeil mot server",
"data_provider_error": "dataProvider feil. Sjekk konsollet for feil.",
"i18n_error": "Klarte ikke laste oversettelser for valgt språk.",
"canceled": "Handling avbrutt",
"logged_out": "Din sesjon er avsluttet, vennligst koble til på nytt.",
"new_version": "Ny versjon tilgjengelig! Vennligst last siden på nytt."
"item_doesnt_exist": "Elementet eksisterer ikke",
"http_error": "Serverkommunikasjonsfeil",
"data_provider_error": "dataleverandørfeil. Sjekk konsollen for detaljer.",
"i18n_error": "Kan ikke laste oversettelsene for det angitte språket",
"canceled": "Handlingen avbrutt",
"logged_out": "Økten din er avsluttet. Koble til på nytt.",
"new_version": "Ny versjon tilgjengelig! Trykk Oppdater "
},
"toggleFieldsMenu": {
"columnsToDisplay": "Vis følgende kolonner",
"layout": "Layout",
"grid": "Rutenett",
"table": "Tabell"
"columnsToDisplay": "Kolonner som skal vises",
"layout": "Oppsett",
"grid": "Nett",
"table": "Bord"
}
},
"message": {
"note": "NOTAT",
"transcodingDisabled": "Endringer på transkodingkonfigurasjon fra web grensesnittet er deaktivert grunnet sikkerhet. Hvis du ønsker å endre eller legge til transkodingsmuligheter, restart serveren med %{config} konfigurasjonsalternativ.",
"transcodingEnabled": "Navidrome kjører for øyeblikket med %{config}, som gjør det mulig å kjøre systemkommandoer fra transkodingsinstillinger i web grensesnittet. Vi anbefaler å deaktivere denne muligheten av sikkerhetsårsaker og heller kun ha det aktivert under konfigurasjon av transkodingsmuligheter.",
"note": "Info",
"transcodingDisabled": "Endring av transkodingskonfigurasjonen gjennom webgrensesnittet er deaktivert av sikkerhetsgrunner. Hvis du ønsker å endre (redigere eller legge til) transkodingsalternativer, start serveren på nytt med %{config}-konfigurasjonsalternativet.",
"transcodingEnabled": "Navidrome kjører for øyeblikket med %{config}, noe som gjør det mulig å kjøre systemkommandoer fra transkodingsinnstillingene ved å bruke nettgrensesnittet. Vi anbefaler å deaktivere den av sikkerhetsgrunner og bare aktivere den når du konfigurerer alternativer for omkoding.",
"songsAddedToPlaylist": "Lagt til 1 sang i spillelisten |||| Lagt til %{smart_count} sanger i spillelisten",
"noPlaylistsAvailable": "Ingen tilgjengelig",
"delete_user_title": "Slett bruker '%{name}'",
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og all tilhørlig data (inkludert spillelister og preferanser)?",
"remove_missing_title": "Fjern manglende filer",
"remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
"notifications_blocked": "Du har blokkert notifikasjoner for denne nettsiden i din nettleser.",
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsnotifikasjoner, eller så er du ikke tilkoblet Navidrome via https.",
"lastfmLinkSuccess": "Last.fm er tilkoblet og scrobbling er aktivert",
"lastfmLinkFailure": "Last.fm kunne ikke koble til",
"lastfmUnlinkSuccess": "Last.fm er avkoblet og scrobbling er deaktivert",
"lastfmUnlinkFailure": "Last.fm kunne ikke avkobles",
"listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
"listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
"delete_user_title": "Slett bruker «%{name}»",
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og alle dataene deres (inkludert spillelister og preferanser)?",
"notifications_blocked": "Du har blokkert varsler for dette nettstedet i nettleserens innstillinger",
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsvarsler, eller du har ikke tilgang til Navidrome over https",
"lastfmLinkSuccess": "Last.fm er vellykket koblet og scrobbling aktivert",
"lastfmLinkFailure": "Last.fm kunne ikke kobles til",
"lastfmUnlinkSuccess": "Last.fm koblet fra og scrobbling deaktivert",
"lastfmUnlinkFailure": "Last.fm kunne ikke kobles fra",
"openIn": {
"lastfm": "Åpne i Last.fm",
"musicbrainz": "Åpne i MusicBrainz"
},
"lastfmLink": "Les Mer...",
"shareOriginalFormat": "Del i originalformat",
"shareDialogTitle": "Del %{resource} '%{name}'",
"shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}",
"shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
"shareSuccess": "URL kopiert til utklippstavle: %{url}",
"shareFailure": "Error ved kopiering av URL %{url} til utklippstavle",
"downloadDialogTitle": "Last ned %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Last ned i originalformat"
"lastfmLink": "Les mer...",
"listenBrainzLinkSuccess": "ListenBrainz er vellykket koblet og scrobbling aktivert som bruker: %{user}",
"listenBrainzLinkFailure": "ListenBrainz kunne ikke kobles: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz koblet fra og scrobbling deaktivert",
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke fjernes",
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": "",
"remove_missing_title": "",
"remove_missing_content": ""
},
"menu": {
"library": "Bibliotek",
"settings": "Instillinger",
"settings": "Innstillinger",
"version": "Versjon",
"theme": "Tema",
"personal": {
@@ -437,81 +434,81 @@
"theme": "Tema",
"language": "Språk",
"defaultView": "Standardvisning",
"desktop_notifications": "Skrivebordsnotifikasjoner",
"lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert",
"desktop_notifications": "Skrivebordsvarsler",
"lastfmScrobbling": "Scrobble til Last.fm",
"listenBrainzScrobbling": "Scrobble til ListenBrainz",
"replaygain": "ReplayGain Mode",
"preAmp": "ReplayGain PreAmp (dB)",
"replaygain": "",
"preAmp": "",
"gain": {
"none": "Deaktivert",
"album": "Bruk Album Gain",
"track": "Bruk Track Gain"
}
"none": "",
"album": "",
"track": ""
},
"lastfmNotConfigured": ""
}
},
"albumList": "Album",
"playlists": "Spillelister",
"sharedPlaylists": "Delte Spillelister",
"about": "Om"
"about": "Om",
"playlists": "Spilleliste",
"sharedPlaylists": "Delte spillelister"
},
"player": {
"playListsText": "Spill Av Kø",
"playListsText": "Spillekø",
"openText": "Åpne",
"closeText": "Lukk",
"notContentText": "Ingen musikk",
"clickToPlayText": "Klikk for å avspille",
"clickToPauseText": "Klikk for å pause",
"clickToPlayText": "Klikk for å spille",
"clickToPauseText": "Klikk for å sette på pause",
"nextTrackText": "Neste spor",
"previousTrackText": "Forrige spor",
"reloadText": "Last på nytt",
"reloadText": "Last inn på nytt",
"volumeText": "Volum",
"toggleLyricText": "Slå på/av sangtekster",
"toggleLyricText": "Veksle mellom tekster",
"toggleMiniModeText": "Minimer",
"destroyText": "Ødelegg",
"downloadText": "Last Ned",
"destroyText": "Ødelegge",
"downloadText": "nedlasting",
"removeAudioListsText": "Slett lydlister",
"clickToDeleteText": "Klikk for å slette %{name}",
"emptyLyricText": "Ingen sangtekster",
"playModeText": {
"order": "I rekkefølge",
"orderLoop": "Repeat",
"singleLoop": "Repeat En",
"shufflePlay": "Shuffle"
"orderLoop": "Gjenta",
"singleLoop": "Gjenta engang",
"shufflePlay": "Tilfeldig rekkefølge"
}
},
"about": {
"links": {
"homepage": "Hjemmeside",
"source": "Kildekode",
"featureRequests": "Funksjonsforespørseler",
"lastInsightsCollection": "Siste Innsamling av anonymisert forbruksdata",
"featureRequests": "Funksjonsforespørsler",
"lastInsightsCollection": "",
"insights": {
"disabled": "Deaktivert",
"waiting": "Venter"
"disabled": "",
"waiting": ""
}
}
},
"activity": {
"title": "Aktivitet",
"totalScanned": "Antall mapper skannet",
"quickScan": "Hurtigskann",
"fullScan": "Full Skann",
"serverUptime": "Server Oppetid",
"totalScanned": "Totalt skannede mapper",
"quickScan": "Rask skanning",
"fullScan": "Full skanning",
"serverUptime": "Serveroppetid",
"serverDown": "OFFLINE"
},
"help": {
"title": "Navidrome Hurtigtaster",
"title": "Navidrome hurtigtaster",
"hotkeys": {
"show_help": "Vis Hjelp",
"toggle_menu": "Åpne/Lukke Sidepanel",
"toggle_play": "Avspill / Pause",
"prev_song": "Forrige Sang",
"next_song": "Neste Sang",
"current_song": "Gå til Nåværende Sang",
"vol_up": "Volum Opp",
"vol_down": "Volum Ned",
"toggle_love": "Legg til spor i favoritter"
"show_help": "Vis denne hjelpen",
"toggle_menu": "Bytt menysidelinje",
"toggle_play": "Spill / Pause",
"prev_song": "Forrige sang",
"next_song": "Neste sang",
"vol_up": "Volum opp",
"vol_down": "Volum ned",
"toggle_love": "Legg til dette sporet i favoritter",
"current_song": ""
}
}
}
}

View File

@@ -33,8 +33,7 @@
"tags": "Дополнительные теги",
"mappedTags": "Сопоставленные теги",
"rawTags": "Исходные теги",
"bitDepth": "Битовая глубина",
"sampleRate": "Частота дискретизации (Гц)"
"bitDepth": "Битовая глубина"
},
"actions": {
"addToQueue": "В очередь",
@@ -73,7 +72,7 @@
"grouping": "Группирование",
"media": "Медиа",
"mood": "Настроение",
"date": "Дата записи"
"date": ""
},
"actions": {
"playAll": "Играть",

View File

@@ -1,517 +1,465 @@
{
"languageName": "српски",
"resources": {
"song": {
"name": "Песма |||| Песме",
"fields": {
"album": "Албум",
"albumArtist": "Уметник албума",
"artist": "Уметник",
"bitDepth": "Битова",
"bitRate": "Битски проток",
"bpm": "BPM",
"channels": "Канала",
"comment": "Коментар",
"compilation": "Компилација",
"createdAt": "Датум додавања",
"discSubtitle": "Поднаслов диска",
"duration": "Трајање",
"genre": "Жанр",
"grouping": "Груписање",
"mappedTags": "Мапиране ознаке",
"mood": "Расположење",
"participants": "Додатни учесници",
"path": "Путања фајла",
"playCount": "Пуштано",
"playDate": "Последње пуштано",
"quality": "Квалитет",
"rating": "Рејтинг",
"rawTags": "Сирове ознаке",
"size": "Величина фајла",
"starred": "Омиљено",
"tags": "Додатне ознаке",
"title": "Наслов",
"trackNumber": "#",
"updatedAt": "Ажурирано",
"year": "Година"
},
"actions": {
"addToPlaylist": "Додај у плејлисту",
"addToQueue": "Пусти касније",
"download": "Преузми",
"info": "Прикажи инфо",
"playNext": "Пусти наредно",
"playNow": "Пусти одмах",
"shuffleAll": "Измешај све"
}
"languageName": "српски",
"resources": {
"song": {
"name": "Песма |||| Песме",
"fields": {
"albumArtist": "Уметник албума",
"duration": "Трајање",
"trackNumber": "#",
"playCount": "Пуштано",
"title": "Наслов",
"artist": "Уметник",
"album": "Албум",
"path": "Путања фајла",
"genre": "Жанр",
"compilation": "Компилација",
"year": "Година",
"size": "Величина фајла",
"updatedAt": "Ажурирано",
"bitRate": "Битски проток",
"channels": "Канала",
"discSubtitle": "Поднаслов диска",
"starred": "Омиљено",
"comment": "Коментар",
"rating": "Рејтинг",
"quality": "Квалитет",
"bpm": "BPM",
"playDate": "Последње пуштано",
"createdAt": "Датум додавања"
},
"actions": {
"addToQueue": "Пусти касније",
"playNow": "Пусти одмах",
"addToPlaylist": "Додај у плејлисту",
"shuffleAll": "Измешај све",
"download": "Преузми",
"playNext": "Пусти наредно",
"info": "Прикажи инфо"
}
},
"album": {
"name": "Албум |||| Албуми",
"fields": {
"albumArtist": "Уметник албума",
"artist": "Уметник",
"duration": "Трајање",
"songCount": "Песме",
"playCount": "Пуштано",
"size": "Величина",
"name": "Назив",
"genre": "Жанр",
"compilation": "Компилација",
"year": "Година",
"originalDate": "Оригинално",
"releaseDate": "Објављено",
"releases": "Издање|||| Издања",
"released": "Објављено",
"updatedAt": "Ажурирано",
"comment": "Коментар",
"rating": "Рејтинг",
"createdAt": "Датум додавања"
},
"actions": {
"playAll": "Пусти",
"playNext": "Пусти наредно",
"addToQueue": "Пусти касније",
"share": "Дели",
"shuffle": "Измешај",
"addToPlaylist": "Додај у плејлисту",
"download": "Преузми",
"info": "Прикажи инфо"
},
"lists": {
"all": "Све",
"random": "Насумично",
"recentlyAdded": "Додато недавно",
"recentlyPlayed": "Пуштано недавно",
"mostPlayed": "Најчешће пуштано",
"starred": "Омиљено",
"topRated": "Најбоље рангирано"
}
},
"artist": {
"name": "Уметник |||| Уметници",
"fields": {
"name": "Име",
"albumCount": "Број албума",
"songCount": "Број песама",
"size": "Величина",
"playCount": "Пуштано",
"rating": "Рејтинг",
"genre": "Жанр"
}
},
"user": {
"name": "Корисник |||| Корисници",
"fields": {
"userName": "Корисничко име",
"isAdmin": "Да ли је Админ",
"lastLoginAt": "Последња пријава",
"lastAccessAt": "Последњи приступ",
"updatedAt": "Ажурирано",
"name": "Име",
"password": "Лозинка",
"createdAt": "Креирана",
"changePassword": "Измени лозинку?",
"currentPassword": "Текућа лозинка",
"newPassword": "Нова лозинка",
"token": "Жетон"
},
"helperTexts": {
"name": "Измене вашег имена ће постати видљиве након следеће пријаве"
},
"notifications": {
"created": "Корисник креиран",
"updated": "Корисник ажуриран",
"deleted": "Корисник обрисан"
},
"message": {
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.",
"clickHereForToken": "Кликните овде да преузмете свој жетон"
}
},
"player": {
"name": "Плејер |||| Плејери",
"fields": {
"name": "Назив",
"transcodingId": "Транскодирање",
"maxBitRate": "Макс. битски проток",
"client": "Клијент",
"userName": "Корисничко име",
"lastSeen": "последњи пут виђен",
"reportRealPath": "Пријављуј реалну путању",
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе"
}
},
"transcoding": {
"name": "Транскодирање |||| Транскодирања",
"fields": {
"name": "Назив",
"targetFormat": "Циљни формат",
"defaultBitRate": "Подразумевани битски проток",
"command": "Команда"
}
},
"playlist": {
"name": "Плејлиста |||| Плејлисте",
"fields": {
"name": "Назив",
"duration": "Трајање",
"ownerName": "Власник",
"public": "Јавна",
"updatedAt": "Ажурирана",
"createdAt": "Креирана",
"songCount": "Песме",
"comment": "Коментар",
"sync": "Ауто-увоз",
"path": "Увоз из"
},
"actions": {
"selectPlaylist": "Изабери плејлисту",
"addNewPlaylist": "Креирај „%{name}”",
"export": "Извоз",
"makePublic": "Учини јавном",
"makePrivate": "Учини приватном"
},
"message": {
"duplicate_song": "Додај дуплиране песме",
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
}
},
"radio": {
"name": "Радио |||| Радији",
"fields": {
"name": "Назив",
"streamUrl": "URL тока",
"homePageUrl": "URL почетне странице",
"updatedAt": "Ажурирано",
"createdAt": "Креирано"
},
"actions": {
"playNow": "Пусти одмах"
}
},
"share": {
"name": "Дељење |||| Дељења",
"fields": {
"username": "Поделио",
"url": "URL",
"description": "Опис",
"downloadable": "Допушта се преузимање?",
"contents": "Садржај",
"expiresAt": "Истиче",
"lastVisitedAt": "Последњи пут посећено",
"visitCount": "Број посета",
"format": "Формат",
"maxBitRate": "Макс. битски проток",
"updatedAt": "Ажурирано",
"createdAt": "Креирано"
},
"notifications": {
},
"actions": {
}
}
},
"album": {
"name": "Албум |||| Албуми",
"fields": {
"albumArtist": "Уметник албума",
"artist": "Уметник",
"catalogNum": "Каталошки број",
"comment": "Коментар",
"compilation": "Компилација",
"createdAt": "Датум додавања",
"date": "Датум снимања",
"duration": "Трајање",
"genre": "Жанр",
"grouping": "Груписање",
"media": "Медијум",
"mood": "Расположење",
"name": "Назив",
"originalDate": "Оригинално",
"playCount": "Пуштано",
"rating": "Рејтинг",
"recordLabel": "Издавачка кућа",
"releaseDate": "Објављено",
"releaseType": "Тип",
"released": "Објављено",
"releases": "Издање|||| Издања",
"size": "Величина",
"songCount": "Песме",
"updatedAt": "Ажурирано",
"year": "Година"
},
"actions": {
"addToPlaylist": "Додај у плејлисту",
"addToQueue": "Пусти касније",
"download": "Преузми",
"info": "Прикажи инфо",
"playAll": "Пусти",
"playNext": "Пусти наредно",
"share": "Дели",
"shuffle": "Измешај"
},
"lists": {
"all": "Све",
"mostPlayed": "Најчешће пуштано",
"random": "Насумично",
"recentlyAdded": "Додато недавно",
"recentlyPlayed": "Пуштано недавно",
"starred": "Омиљено",
"topRated": "Најбоље рангирано"
}
},
"artist": {
"name": "Уметник |||| Уметници",
"fields": {
"albumCount": "Број албума",
"genre": "Жанр",
"name": "Назив",
"playCount": "Пуштано",
"rating": "Рејтинг",
"role": "Улога",
"size": "Величина",
"songCount": "Број песама"
},
"roles": {
"albumartist": "Уметник албума |||| Уметници албума",
"arranger": "Аранжер |||| Аранжери",
"artist": "Уметник |||| Уметници",
"composer": "Композитор |||| Композитори",
"conductor": "Диригент |||| Диригенти",
"director": "Режисер |||| Режисери",
"djmixer": "Ди-џеј миксер |||| Ди-џеј миксер",
"engineer": "Инжењер |||| Инжењери",
"lyricist": "Текстописац |||| Текстописци",
"mixer": "Миксер |||| Миксери",
"performer": "Извођач |||| Извођачи",
"producer": "Продуцент |||| Продуценти",
"remixer": "Ремиксер |||| Ремиксери"
}
},
"user": {
"name": "Корисник |||| Корисници",
"fields": {
"changePassword": "Измени лозинку?",
"createdAt": "Креирана",
"currentPassword": "Текућа лозинка",
"isAdmin": "Да ли је Админ",
"lastAccessAt": "Последњи приступ",
"lastLoginAt": "Последња пријава",
"name": "Назив",
"newPassword": "Нова лозинка",
"password": "Лозинка",
"token": "Жетон",
"updatedAt": "Ажурирано",
"userName": "Корисничко име"
},
"helperTexts": {
"name": "Измене вашег имена ће постати видљиве након следеће пријаве"
},
"notifications": {
"created": "Корисник креиран",
"deleted": "Корисник обрисан",
"updated": "Корисник ажуриран"
},
"message": {
"clickHereForToken": "Кликните овде да преузмете свој жетон",
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон."
}
},
"player": {
"name": "Плејер |||| Плејери",
"fields": {
"client": "Клијент",
"lastSeen": "Последњи пут виђен",
"maxBitRate": "Макс. битски проток",
"name": "Назив",
"reportRealPath": "Пријављуј реалну путању",
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе",
"transcodingId": "Транскодирање",
"userName": "Корисничко име"
}
},
"transcoding": {
"name": "Транскодирање |||| Транскодирања",
"fields": {
"command": "Команда",
"defaultBitRate": "Подразумевани битски проток",
"name": "Назив",
"targetFormat": "Циљни формат"
}
},
"playlist": {
"name": "Плејлиста |||| Плејлисте",
"fields": {
"comment": "Коментар",
"createdAt": "Креирана",
"duration": "Трајање",
"name": "Назив",
"ownerName": "Власник",
"path": "Увоз из",
"public": "Јавна",
"songCount": "Песме",
"sync": "Ауто-увоз",
"updatedAt": "Ажурирано"
},
"actions": {
"addNewPlaylist": "Креирај „%{name}”",
"export": "Извези",
"makePrivate": "Учини приватном",
"makePublic": "Учини јавном",
"selectPlaylist": "Изабери плејлисту"
},
"message": {
"duplicate_song": "Додај дуплиране песме",
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
}
},
"radio": {
"name": "Радио |||| Радији",
"fields": {
"createdAt": "Креирана",
"homePageUrl": "URL почетне странице",
"name": "Назив",
"streamUrl": "URL тока",
"updatedAt": "Ажурирано"
},
"actions": {
"playNow": "Пусти одмах"
}
},
"share": {
"name": "Дељење |||| Дељења",
"fields": {
"contents": "Садржај",
"createdAt": "Креирано",
"description": "Опис",
"downloadable": "Допушта се преузимање?",
"expiresAt": "Истиче",
"format": "Формат",
"lastVisitedAt": "Последњи пут посећено",
"maxBitRate": "Макс. битски проток",
"updatedAt": "Ажурирано",
"url": "URL",
"username": "Поделио",
"visitCount": "Број посета"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "Фајл који недостаје|||| Фајлови који недостају",
"empty": "Нема фајлова који недостају",
"fields": {
"path": "Путања",
"size": "Величина",
"updatedAt": "Нестао дана"
},
"actions": {
"remove": "Уклони"
},
"notifications": {
"removed": "Фајл који недостаје, или више њих, је уклоњен"
}
}
},
"ra": {
"auth": {
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
"buttonCreateAdmin": "Креирај админа",
"confirmPassword": "Потврдите лозинку",
"insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите",
"logout": "Одјави се",
"password": "Лозинка",
"sign_in": "Пријави се",
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
"user_menu": "Профил",
"username": "Корисничко име",
"welcome1": "Хвала што сте инсталирали Navidrome!",
"welcome2": "За почетак, креирајте админ корисника"
},
"validation": {
"email": "Мора да буде исправна и-мејл адреса",
"invalidChars": "Молимо вас да користите само слова и цифре",
"maxLength": "Мора да буде %{max} карактера или мање",
"maxValue": "Мора да буде %{max} или мање",
"minLength": "Мора да буде барем %{min} карактера",
"minValue": "Мора да буде барем %{min}",
"number": "Мора да буде број",
"oneOf": "Мора да буде једно од: %{options}",
"passwordDoesNotMatch": "Лозинка се не подудара",
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
"required": "Неопходно",
"unique": "Мора да буде јединствено",
"url": "Мора да буде исправна URL адреса"
},
"action": {
"add": "Додај",
"add_filter": "Додај филтер",
"back": "Иди назад",
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Откажи",
"clear_input_value": "Обриши вредност",
"clone": "Клонирај",
"close": "Затвори",
"close_menu": "Затвори мени",
"confirm": "Потврди",
"create": "Креирај",
"delete": "Обриши",
"download": "Преузми",
"edit": "Уреди",
"expand": "Развиј",
"export": "Извези",
"list": "Листа",
"open_menu": "Отвори мени",
"refresh": "Освежи",
"remove": "Уклони",
"remove_filter": "Уклони овај филтер",
"save": "Сачувај",
"search": "Тражи",
"share": "Дели",
"show": "Прикажи",
"skip": "Прескочи",
"sort": "Сортирај",
"undo": "Поништи",
"unselect": "Уклони избор"
},
"boolean": {
"false": "Не",
"true": "Да"
},
"page": {
"create": "Креирај %{name}",
"dashboard": "Контролна табла",
"edit": "%{name} #%{id}",
"empty": "Још увек нема %{name}.",
"error": "Нешто је пошло наопако",
"invite": "Желите ли да се дода?",
"list": "%{name}",
"loading": "Учитава се",
"not_found": "Није пронађено",
"show": "%{name} #%{id}"
},
"input": {
"file": {
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
},
"image": {
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
},
"password": {
"toggle_hidden": "Прикажи лозинку",
"toggle_visible": "Сакриј лозинку"
},
"references": {
"all_missing": "Не могу да се нађу подаци референци.",
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
"single_missing": "Изгледа да придружена референца више није доступна."
}
"ra": {
"auth": {
"welcome1": "Хвала што сте инсталирали Navidrome!",
"welcome2": "За почетак, креирајте админ корисника",
"confirmPassword": "Потврдите лозинку",
"buttonCreateAdmin": "Креирај админа",
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
"user_menu": "Профил",
"username": "Корисничко име",
"password": "Лозинка",
"sign_in": "Пријави се",
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
"logout": "Одјави се"
},
"validation": {
"invalidChars": "Молимо вас да користите само слова и цифре",
"passwordDoesNotMatch": "Лозинка се не подудара",
"required": "Неопходно",
"minLength": "Мора да буде барем %{min} карактера",
"maxLength": "Мора да буде %{max} карактера или мање",
"minValue": "Мора да буде барем %{min}",
"maxValue": "Мора да буде %{max} или мање",
"number": "Мора да буде број",
"email": "Мора да буде исправна и-мејл адреса",
"oneOf": "Мора да буде једно од: %{options}",
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
"unique": "Мора да буде јединствено",
"url": "Мора да буде исправна URL адреса"
},
"action": {
"add_filter": "Додај филтер",
"add": "Додај",
"back": "Иди назад",
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Откажи",
"clear_input_value": "Обриши вредност",
"clone": "Клонирај",
"confirm": "Потврди",
"create": "Креирај",
"delete": "Обриши",
"edit": "Уреди",
"export": "Извези",
"list": "Листа",
"refresh": "Освежи",
"remove_filter": "Уклони овај филтер",
"remove": "Уклони",
"save": "Сачувај",
"search": "Тражи",
"show": "Прикажи",
"sort": "Сортирај",
"undo": "Поништи",
"expand": "Развиј",
"close": "Затвори",
"open_menu": "Отвори мени",
"close_menu": "Затвори мени",
"unselect": "Уклони избор",
"skip": "Прескочи",
"share": "Подели",
"download": "Преузми"
},
"boolean": {
"true": а",
"false": "Не"
},
"page": {
"create": "Креирај %{name}",
"dashboard": "Контролна табла",
"edit": "%{name} #%{id}",
"error": "Нешто је пошло наопако",
"list": "%{name}",
"loading": "Учитава се",
"not_found": "Није пронађено",
"show": "%{name} #%{id}",
"empty": "Још увек нема %{name}.",
"invite": "Желите ли да се дода?"
},
"input": {
"file": {
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
},
"image": {
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
},
"references": {
"all_missing": "Не могу да се нађу подаци референци.",
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
"single_missing": "Изгледа да придружена референца више није доступна."
},
"password": {
"toggle_visible": "Сакриј лозинку",
"toggle_hidden": "Прикажи лозинку"
}
},
"message": {
"about": "О програму",
"are_you_sure": "Да ли сте сигурни?",
"bulk_delete_content": "Да ли заиста желите да обришете %{name}? |||| Да ли заиста желите да обришете %{smart_count} ставке?",
"bulk_delete_title": "Брисање %{name} |||| Брисање %{smart_count} %{name}",
"delete_content": "Да ли заиста желите да обришете ову ставку?",
"delete_title": "Брисање %{name} #%{id}",
"details": "Детаљи",
"error": "Дошло је до клијентске грешке и ваш захтев није могао да се изврши.",
"invalid_form": "Формулар није исправан. Молимо вас да исправите грешке",
"loading": "Страница се учитава, сачекајте мало",
"no": "Не",
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
"yes": "Да",
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?"
},
"navigation": {
"no_results": "Није пронађен ниједан резултат",
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
"page_out_from_end": "Не може да се иде након последње странице",
"page_out_from_begin": "Не може да се иде испред странице 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
"page_rows_per_page": "Ставки по страници:",
"next": "Наредна",
"prev": "Претход",
"skip_nav": "Прескочи на садржај"
},
"notification": {
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано",
"created": "Елемент је креиран",
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
"bad_item": "Неисправни елемент",
"item_doesnt_exist": "Елемент не постоји",
"http_error": "Грешка у комуникацији са сервером",
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
"i18n_error": "Не могу да се учитају преводи за наведени језик",
"canceled": "Акција је отказана",
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Колоне за приказ",
"layout": "Распоред",
"grid": "Мрежа",
"table": "Табела"
}
},
"message": {
"about": "О",
"are_you_sure": "Да ли сте сигурни?",
"bulk_delete_content": "Да ли заиста желите да обришете %{name}? |||| Да ли заиста желите да обришете %{smart_count} ставке?",
"bulk_delete_title": "Брисање %{name} |||| Брисање %{smart_count} %{name}",
"delete_content": "Да ли заиста желите да обришете ову ставку?",
"delete_title": "Брисање %{name} #%{id}",
"details": "Детаљи",
"error": "Дошло је до клијентске грешке и ваш захтев није могао да се изврши.",
"invalid_form": "Формулар није исправан. Молимо вас да исправите грешке",
"loading": "Страница се учитава, сачекајте мало",
"no": "Не",
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?",
"yes": "Да"
},
"navigation": {
"next": "Наредна",
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
"no_results": "Није пронађен ниједан резултат",
"page_out_from_begin": "Не може да се иде испред странице 1",
"page_out_from_end": "Не може да се иде након последње странице",
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
"page_rows_per_page": "Ставки по страници:",
"prev": "Претход",
"skip_nav": "Прескочи на садржај"
},
"notification": {
"bad_item": "Неисправни елемент",
"canceled": "Акција је отказана",
"created": "Елемент је креиран",
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
"http_error": "Грешка у комуникацији са сервером",
"i18n_error": "Не могу да се учитају преводи за наведени језик",
"item_doesnt_exist": "Елемент не постоји",
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.",
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано"
},
"toggleFieldsMenu": {
"columnsToDisplay": "Колоне за приказ",
"grid": "Мрежа",
"layout": "Распоред",
"table": "Табела"
}
},
"message": {
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
"delete_user_title": "Брисање корисника %{name}",
"downloadDialogTitle": "Преузимање %{resource} %{name} (%{size})",
"downloadOriginalFormat": "Преузми у оригиналном формату",
"lastfmLink": "Прочитај још...",
"lastfmLinkFailure": "Last.fm није могао да се повеже",
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
"noPlaylistsAvailable": "Није доступна ниједна",
"note": "НАПОМЕНА",
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
"openIn": {
"lastfm": "Отвори у Last.fm",
"musicbrainz": "Отвори у MusicBrainz"
},
"remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.",
"remove_missing_title": "Уклони фајлове који недостају",
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
"shareDialogTitle": "Подели %{resource} %{name}",
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
"shareOriginalFormat": "Подели у оригиналном формату",
"shareSuccess": "URL је копиран у клипборд: %{url}",
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања."
},
"menu": {
"about": "О",
"albumList": "Албуми",
"library": "Библиотека",
"personal": {
"name": "Лична",
"options": {
"defaultView": "Подразумевани поглед",
"desktop_notifications": "Десктоп обавештења",
"gain": {
"album": "Користи Album појачање",
"none": "Искључено",
"track": "Користи Track појачање"
"note": "НАПОМЕНА",
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.",
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
"noPlaylistsAvailable": "Није доступна ниједна",
"delete_user_title": "Брисање корисника %{name}",
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
"lastfmLinkFailure": "Last.fm није могао да се повеже",
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
"openIn": {
"lastfm": "Отвори у Last.fm",
"musicbrainz": "Отвори у MusicBrainz"
},
"language": "Језик",
"lastfmNotConfigured": "Није подешен Last.fm API-кључ",
"lastfmScrobbling": "Скроблуј на Last.fm",
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
"preAmp": "ReplayGain претпојачање (dB)",
"replaygain": "ReplayGain режим",
"theme": "Тема"
}
"lastfmLink": "Прочитај још...",
"shareOriginalFormat": "Подели у оригиналном формату",
"shareDialogTitle": "Подели %{resource} %{name}",
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
"shareSuccess": "URL је копиран у клипборд: %{url}",
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
"downloadDialogTitle": "Преузимање %{resource} %{name} (%{size})",
"downloadOriginalFormat": "Преузми у оригиналном формату"
},
"playlists": "Плејлисте",
"settings": "Подешавања",
"sharedPlaylists": "Дељене плејлисте",
"theme": "Тема",
"version": "Верзија"
},
"player": {
"clickToDeleteText": "Кликните да обришете %{name}",
"clickToPauseText": "Кликни за паузирање",
"clickToPlayText": "Кликни за пуштање",
"closeText": "Затвори",
"destroyText": "Уништи",
"downloadText": "Преузми",
"emptyLyricText": "Нема стихова",
"nextTrackText": "Наредна нумера",
"notContentText": "Нема музике",
"openText": "Отвори",
"playListsText": "Ред за пуштање",
"playModeText": {
"order": "По редоследу",
"orderLoop": "Понови",
"shufflePlay": "Измешај",
"singleLoop": "Понови једну"
"menu": {
"library": "Библиотека",
"settings": "Подешавања",
"version": "Верзија",
"theme": "Тема",
"personal": {
"name": "Лична",
"options": {
"theme": "Тема",
"language": "Језик",
"defaultView": "Подразумевани поглед",
"desktop_notifications": "Десктоп обавештења",
"lastfmScrobbling": "Скроблуј на Last.fm",
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
"replaygain": "ReplayGain режим",
"preAmp": "ReplayGain претпојачање (dB)",
"gain": {
"none": "Искључено",
"album": "Користи Album појачање",
"track": "Користи Track појачање"
}
}
},
"albumList": "Албуми",
"playlists": "Плејлисте",
"sharedPlaylists": "Дељене плејлисте",
"about": "О"
},
"previousTrackText": "Претходна нумера",
"reloadText": "Поново учитај",
"removeAudioListsText": "Обриши аудио листе",
"toggleLyricText": "Укљ./Искљ. стихове",
"toggleMiniModeText": "Умањи",
"volumeText": "Јачина"
},
"about": {
"links": {
"featureRequests": "Захтеви за функцијама",
"homepage": "Почетна страница",
"insights": {
"disabled": "Искључено",
"waiting": "Чека се"
},
"lastInsightsCollection": "Последња колекција увида",
"source": "Изворни кôд"
"player": {
"playListsText": "Ред за пуштање",
"openText": "Отвори",
"closeText": "Затвори",
"notContentText": "Нема музике",
"clickToPlayText": "Кликни за пуштање",
"clickToPauseText": "Кликни за паузирање",
"nextTrackText": "Наредна нумера",
"previousTrackText": "Претходна нумера",
"reloadText": "Поново учитај",
"volumeText": "Јачина",
"toggleLyricText": "Укљ./Искљ. стихове",
"toggleMiniModeText": "Умањи",
"destroyText": "Уништи",
"downloadText": "Преузми",
"removeAudioListsText": "Обриши аудио листе",
"clickToDeleteText": "Кликните да обришете %{name}",
"emptyLyricText": "Нема стихова",
"playModeText": {
"order": "По редоследу",
"orderLoop": "Понови",
"singleLoop": "Понови једну",
"shufflePlay": "Промешано"
}
},
"about": {
"links": {
"homepage": "Почетна страница",
"source": "Изворни кôд",
"featureRequests": "Захтеви за функцијама"
}
},
"activity": {
"title": "Активност",
"totalScanned": "Укупан број скенираних фолдера",
"quickScan": "Брзо скенирање",
"fullScan": "Комплетно скенирање",
"serverUptime": "Сервер се извршава",
"serverDown": "ВАН МРЕЖЕ"
},
"help": {
"title": "Navidrome пречице",
"hotkeys": {
"show_help": "Прикажи ову помоћ",
"toggle_menu": "Укљ./Искљ. бочну траку менија",
"toggle_play": "Пусти / Паузирај",
"prev_song": "Претходна песма",
"next_song": "Наредна песма",
"current_song": "Иди на текућу песму",
"vol_up": "Појачај",
"vol_down": "Утишај",
"toggle_love": "Додај ову нумеру у омиљене"
}
}
},
"activity": {
"fullScan": "Комплетно скенирање",
"quickScan": "Брзо скенирање",
"serverDown": "ВАН МРЕЖЕ",
"serverUptime": "Сервер се извршава",
"title": "Активност",
"totalScanned": "Укупан број скенираних фолдера"
},
"help": {
"title": "Navidrome пречице",
"hotkeys": {
"current_song": "Иди на текућу песму",
"next_song": "Наредна песма",
"prev_song": "Претходна песма",
"show_help": "Прикажи ову помоћ",
"toggle_love": "Додај ову нумеру у омиљене",
"toggle_menu": "Укљ./Искљ. бочну траку менија",
"toggle_play": "Пусти / Паузирај",
"vol_down": "Утишај",
"vol_up": "Појачај"
}
}
}

View File

@@ -33,8 +33,7 @@
"tags": "Ek Etiketler",
"mappedTags": "Eşlenen etiketler",
"rawTags": "Ham etiketler",
"bitDepth": "Bit derinliği",
"sampleRate": "Örnekleme Oranı"
"bitDepth": "Bit derinliği"
},
"actions": {
"addToQueue": "Oynatma Sırasına Ekle",

View File

@@ -12,14 +12,12 @@
"artist": "歌手",
"album": "专辑",
"path": "文件路径",
"genre": "流派",
"genre": "类型",
"compilation": "合辑",
"year": "发行年份",
"size": "文件大小",
"updatedAt": "更新于",
"bitRate": "比特率",
"bitDepth": "比特深度",
"channels": "声道",
"discSubtitle": "字幕",
"starred": "收藏",
"comment": "注释",
@@ -27,13 +25,8 @@
"quality": "品质",
"bpm": "BPM",
"playDate": "最后一次播放",
"createdAt": "创建于",
"grouping": "分组",
"mood": "情绪",
"participants": "其他参与人员",
"tags": "附加标签",
"mappedTags": "映射标签",
"rawTags": "原始标签"
"channels": "声道",
"createdAt": "创建于"
},
"actions": {
"addToQueue": "加入播放列表",
@@ -53,36 +46,29 @@
"duration": "时长",
"songCount": "歌曲数量",
"playCount": "播放次数",
"size": "文件大小",
"name": "名称",
"genre": "流派",
"genre": "类型",
"compilation": "合辑",
"year": "发行年份",
"date": "录制日期",
"originalDate": "原始日期",
"releaseDate": "发⾏日期",
"releases": "发⾏",
"released": "已发⾏",
"updatedAt": "更新于",
"comment": "注释",
"rating": "评分",
"createdAt": "创建于",
"recordLabel": "厂牌",
"catalogNum": "目录编号",
"releaseType": "发行类型",
"grouping": "分组",
"media": "媒体类型",
"mood": "情绪"
"size": "文件大小",
"originalDate": "原始日期",
"releaseDate": "发⾏日期",
"releases": "发⾏",
"released": "已发⾏"
},
"actions": {
"playAll": "立即播放",
"playNext": "下首播放",
"addToQueue": "加入播放列表",
"share": "分享",
"shuffle": "随机播放",
"addToPlaylist": "加入歌单",
"download": "下载",
"info": "查看信息"
"info": "查看信息",
"share": "分享"
},
"lists": {
"all": "所有",
@@ -100,26 +86,10 @@
"name": "名称",
"albumCount": "专辑数",
"songCount": "歌曲数",
"size": "文件大小",
"playCount": "播放次数",
"rating": "评分",
"genre": "流派",
"role": "参与角色"
},
"roles": {
"albumartist": "专辑歌手",
"artist": "歌手",
"composer": "作曲",
"conductor": "指挥",
"lyricist": "作词",
"arranger": "编曲",
"producer": "制作人",
"director": "总监",
"engineer": "工程师",
"mixer": "混音师",
"remixer": "重混师",
"djmixer": "DJ混音师",
"performer": "演奏家"
"genre": "类型",
"size": "文件大小"
}
},
"user": {
@@ -128,7 +98,6 @@
"userName": "用户名",
"isAdmin": "是否管理员",
"lastLoginAt": "上次登录",
"lastAccessAt": "上次访问",
"updatedAt": "更新于",
"name": "名称",
"password": "密码",
@@ -139,7 +108,7 @@
"token": "令牌"
},
"helperTexts": {
"name": "名称的更改将在下次登录生效"
"name": "你名字的更改将在下次登录生效"
},
"notifications": {
"created": "用户已创建",
@@ -218,7 +187,6 @@
"username": "分享者",
"url": "链接",
"description": "描述",
"downloadable": "是否允许下载?",
"contents": "目录",
"expiresAt": "过期于",
"lastVisitedAt": "上次访问于",
@@ -226,24 +194,8 @@
"format": "格式",
"maxBitRate": "最大比特率",
"updatedAt": "更新于",
"createdAt": "创建于"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "丢失文件",
"empty": "无丢失文件",
"fields": {
"path": "路径",
"size": "文件大小",
"updatedAt": "丢失于"
},
"actions": {
"remove": "移除"
},
"notifications": {
"removed": "丢失文件已移除"
"createdAt": "创建于",
"downloadable": "是否允许下载"
}
}
},
@@ -259,8 +211,7 @@
"password": "密码",
"sign_in": "登录",
"sign_in_error": "验证失败,请重试",
"logout": "注销",
"insightsCollectionNote": "Navidrome 会收集匿名使用数据以协助改进项目。\n点击[此处]了解详情或选择退出。"
"logout": "注销"
},
"validation": {
"invalidChars": "请使用字母和数字",
@@ -282,7 +233,6 @@
"add": "添加",
"back": "返回",
"bulk_actions": "选中 %{smart_count} 项",
"bulk_actions_mobile": "%{smart_count}",
"cancel": "取消",
"clear_input_value": "清除",
"clone": "复制",
@@ -306,6 +256,7 @@
"close_menu": "关闭菜单",
"unselect": "未选择",
"skip": "跳过",
"bulk_actions_mobile": "%{smart_count}",
"share": "分享",
"download": "下载"
},
@@ -400,31 +351,29 @@
"noPlaylistsAvailable": "没有有效的歌单",
"delete_user_title": "删除用户 %{name}",
"delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?",
"remove_missing_title": "移除丢失文件",
"remove_missing_content": "您确定要将选中的丢失文件从数据库中永久移除吗?此操作将删除所有相关信息,包括播放次数和评分。",
"notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知",
"notifications_not_available": "此浏览器不支持桌面通知",
"lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录",
"lastfmLinkFailure": "Last.fm 无法关联",
"lastfmUnlinkSuccess": "已成功解除与 Last.fm 的链接,且喜好记录已禁用",
"lastfmUnlinkFailure": "Last.fm 无法取消关联",
"listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录",
"listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}",
"listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用",
"listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联",
"openIn": {
"lastfm": "在 Last.fm 中打开",
"musicbrainz": "在 MusicBrainz 中打开"
},
"lastfmLink": "查看更多…",
"listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录",
"listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}",
"listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用",
"listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联",
"downloadOriginalFormat": "下载原始格式",
"shareOriginalFormat": "分享原始格式",
"shareDialogTitle": "分享 %{resource} '%{name}'",
"shareBatchDialogTitle": "分享 %{smart_count} 个 %{resource}",
"shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter",
"shareSuccess": "分享链接已复制: %{url}",
"shareFailure": "分享链接复制失败: %{url}",
"downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "下载原始格式"
"shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter"
},
"menu": {
"library": "曲库",
@@ -438,7 +387,6 @@
"language": "语言",
"defaultView": "默认界面",
"desktop_notifications": "桌面通知",
"lastfmNotConfigured": "没有配置 Last.fm 的 API-Key",
"lastfmScrobbling": "启用 Last.fm 的喜好记录",
"listenBrainzScrobbling": "启用 ListenBrainz 的喜好记录",
"replaygain": "回放增益",
@@ -451,9 +399,9 @@
}
},
"albumList": "专辑",
"about": "关于",
"playlists": "歌单",
"sharedPlaylists": "共享的歌单",
"about": "关于"
"sharedPlaylists": "共享的歌单"
},
"player": {
"playListsText": "播放列表",
@@ -484,12 +432,7 @@
"links": {
"homepage": "主页",
"source": "源代码",
"featureRequests": "功能需求",
"lastInsightsCollection": " 最近的分析收集",
"insights": {
"disabled": "禁用",
"waiting": "等待"
}
"featureRequests": "功能需求"
}
},
"activity": {
@@ -508,10 +451,10 @@
"toggle_play": "播放/暂停",
"prev_song": "上一首歌",
"next_song": "下一首歌",
"current_song": "转到当前播放",
"vol_up": "增大音量",
"vol_down": "减小音量",
"toggle_love": "添加/移除星标"
"toggle_love": "添加/移除星标",
"current_song": "转到当前播放"
}
}
}

View File

@@ -108,7 +108,7 @@ main:
bpm:
aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ]
lyrics:
aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics, unsyncedlyrics ]
aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics ]
maxLength: 32768
type: pair # ex: lyrics:eng, lyrics:xxx
comment:

View File

@@ -1,466 +0,0 @@
# 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.

View File

@@ -11,7 +11,6 @@ import (
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
@@ -267,10 +266,6 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
if dirEnt.Type()&fs.ModeSymlink == 0 {
return false, nil
}
// If symlinks are disabled, return false for symlinks
if !conf.Server.Scanner.FollowSymlinks {
return false, nil
}
// Does this symlink point to a directory?
fileInfo, err := fs.Stat(fsys, path.Join(baseDir, dirEnt.Name()))
if err != nil {

View File

@@ -8,8 +8,6 @@ import (
"path/filepath"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
@@ -19,15 +17,8 @@ import (
var _ = Describe("walk_dir_tree", func() {
Describe("walkDirTree", func() {
var (
fsys storage.MusicFS
job *scanJob
ctx context.Context
)
var fsys storage.MusicFS
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = GinkgoT().Context()
fsys = &mockMusicFS{
FS: fstest.MapFS{
"root/a/.ndignore": {Data: []byte("ignored/*")},
@@ -41,22 +32,21 @@ var _ = Describe("walk_dir_tree", func() {
"root/d/f1.mp3": {},
"root/d/f2.mp3": {},
"root/d/f3.mp3": {},
"root/e/original/f1.mp3": {},
"root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("root/e/original")},
},
}
job = &scanJob{
fs: fsys,
lib: model.Library{Path: "/music"},
}
})
// Helper function to call walkDirTree and collect folders from the results channel
getFolders := func() map[string]*folderEntry {
It("walks all directories", func() {
job := &scanJob{
fs: fsys,
lib: model.Library{Path: "/music"},
}
ctx := context.Background()
results, err := walkDirTree(ctx, job)
Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{}
g := errgroup.Group{}
g.Go(func() error {
for folder := range results {
@@ -65,42 +55,24 @@ var _ = Describe("walk_dir_tree", func() {
return nil
})
_ = g.Wait()
return folders
}
DescribeTable("symlink handling",
func(followSymlinks bool, expectedFolderCount int) {
conf.Server.Scanner.FollowSymlinks = followSymlinks
folders := getFolders()
Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root`
// Basic folder structure checks
Expect(folders["root/a"].audioFiles).To(SatisfyAll(
HaveLen(2),
HaveKey("f1.mp3"),
HaveKey("f2.mp3"),
))
Expect(folders["root/a"].imageFiles).To(BeEmpty())
Expect(folders["root/b"].audioFiles).To(BeEmpty())
Expect(folders["root/b"].imageFiles).To(SatisfyAll(
HaveLen(1),
HaveKey("cover.jpg"),
))
Expect(folders["root/c"].audioFiles).To(BeEmpty())
Expect(folders["root/c"].imageFiles).To(BeEmpty())
Expect(folders).ToNot(HaveKey("root/d"))
// Symlink specific checks
if followSymlinks {
Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
} else {
Expect(folders).ToNot(HaveKey("root/e/symlink"))
}
},
Entry("with symlinks enabled", true, 7),
Entry("with symlinks disabled", false, 6),
)
Expect(folders).To(HaveLen(6))
Expect(folders["root/a/ignored"].audioFiles).To(BeEmpty())
Expect(folders["root/a"].audioFiles).To(SatisfyAll(
HaveLen(2),
HaveKey("f1.mp3"),
HaveKey("f2.mp3"),
))
Expect(folders["root/a"].imageFiles).To(BeEmpty())
Expect(folders["root/b"].audioFiles).To(BeEmpty())
Expect(folders["root/b"].imageFiles).To(SatisfyAll(
HaveLen(1),
HaveKey("cover.jpg"),
))
Expect(folders["root/c"].audioFiles).To(BeEmpty())
Expect(folders["root/c"].imageFiles).To(BeEmpty())
Expect(folders).ToNot(HaveKey("root/d"))
})
})
Describe("helper functions", func() {
@@ -109,88 +81,74 @@ var _ = Describe("walk_dir_tree", func() {
baseDir := filepath.Join("tests", "fixtures")
Describe("isDirOrSymlinkToDir", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
It("returns true for normal dirs", func() {
dirEntry := getDirEntry("tests", "fixtures")
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeTrue())
})
Context("with symlinks enabled", func() {
BeforeEach(func() {
conf.Server.Scanner.FollowSymlinks = true
})
DescribeTable("returns expected result",
func(dirName string, expected bool) {
dirEntry := getDirEntry("tests/fixtures", dirName)
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(Equal(expected))
},
Entry("normal dir", "empty_folder", true),
Entry("symlink to dir", "symlink2dir", true),
Entry("regular file", "test.mp3", false),
Entry("symlink to file", "symlink", false),
)
It("returns true for symlinks to dirs", func() {
dirEntry := getDirEntry(baseDir, "symlink2dir")
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeTrue())
})
Context("with symlinks disabled", func() {
BeforeEach(func() {
conf.Server.Scanner.FollowSymlinks = false
})
DescribeTable("returns expected result",
func(dirName string, expected bool) {
dirEntry := getDirEntry("tests/fixtures", dirName)
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(Equal(expected))
},
Entry("normal dir", "empty_folder", true),
Entry("symlink to dir", "symlink2dir", false),
Entry("regular file", "test.mp3", false),
Entry("symlink to file", "symlink", false),
)
It("returns false for files", func() {
dirEntry := getDirEntry(baseDir, "test.mp3")
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeFalse())
})
It("returns false for symlinks to files", func() {
dirEntry := getDirEntry(baseDir, "symlink")
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeFalse())
})
})
Describe("isDirIgnored", func() {
DescribeTable("returns expected result",
func(dirName string, expected bool) {
Expect(isDirIgnored(dirName)).To(Equal(expected))
},
Entry("normal dir", "empty_folder", false),
Entry("hidden dir", ".hidden_folder", true),
Entry("dir starting with ellipsis", "...unhidden_folder", false),
Entry("recycle bin", "$Recycle.Bin", true),
Entry("snapshot dir", "#snapshot", true),
)
})
Describe("fullReadDir", func() {
var (
fsys fakeFS
ctx context.Context
)
BeforeEach(func() {
ctx = GinkgoT().Context()
fsys = fakeFS{MapFS: fstest.MapFS{
"root/a/f1": {},
"root/b/f2": {},
"root/c/f3": {},
}}
It("returns false for normal dirs", func() {
Expect(isDirIgnored("empty_folder")).To(BeFalse())
})
It("returns true when folder name starts with a `.`", func() {
Expect(isDirIgnored(".hidden_folder")).To(BeTrue())
})
It("returns false when folder name starts with ellipses", func() {
Expect(isDirIgnored("...unhidden_folder")).To(BeFalse())
})
It("returns true when folder name is $Recycle.Bin", func() {
Expect(isDirIgnored("$Recycle.Bin")).To(BeTrue())
})
It("returns true when folder name is #snapshot", func() {
Expect(isDirIgnored("#snapshot")).To(BeTrue())
})
})
})
DescribeTable("reading directory entries",
func(failOn string, expectedErr error, expectedNames []string) {
fsys.failOn = failOn
fsys.err = expectedErr
dir, _ := fsys.Open("root")
entries := fullReadDir(ctx, dir.(fs.ReadDirFile))
Expect(entries).To(HaveLen(len(expectedNames)))
for i, name := range expectedNames {
Expect(entries[i].Name()).To(Equal(name))
}
},
Entry("reads all entries", "", nil, []string{"a", "b", "c"}),
Entry("skips entries with permission error", "b", nil, []string{"a", "c"}),
Entry("aborts on fs.ErrNotExist", "", fs.ErrNotExist, []string{}),
)
Describe("fullReadDir", func() {
var fsys fakeFS
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
fsys = fakeFS{MapFS: fstest.MapFS{
"root/a/f1": {},
"root/b/f2": {},
"root/c/f3": {},
}}
})
It("reads all entries", func() {
dir, _ := fsys.Open("root")
entries := fullReadDir(ctx, dir.(fs.ReadDirFile))
Expect(entries).To(HaveLen(3))
Expect(entries[0].Name()).To(Equal("a"))
Expect(entries[1].Name()).To(Equal("b"))
Expect(entries[2].Name()).To(Equal("c"))
})
It("skips entries with permission error", func() {
fsys.failOn = "b"
dir, _ := fsys.Open("root")
entries := fullReadDir(ctx, dir.(fs.ReadDirFile))
Expect(entries).To(HaveLen(2))
Expect(entries[0].Name()).To(Equal("a"))
Expect(entries[1].Name()).To(Equal("c"))
})
It("aborts if it keeps getting 'readdirent: no such file or directory'", func() {
fsys.err = fs.ErrNotExist
dir, _ := fsys.Open("root")
entries := fullReadDir(ctx, dir.(fs.ReadDirFile))
Expect(entries).To(BeEmpty())
})
})
})
@@ -247,54 +205,11 @@ func getDirEntry(baseDir, name string) os.DirEntry {
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
}
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
type mockMusicFS struct {
storage.MusicFS
fs.FS
}
// Open resolves symlinks
func (m *mockMusicFS) Open(name string) (fs.File, error) {
f, err := m.FS.Open(name)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
if info.Mode()&fs.ModeSymlink != 0 {
// For symlinks, read the target path from the Data field
target := string(m.FS.(fstest.MapFS)[name].Data)
f.Close()
return m.FS.Open(target)
}
return f, nil
}
// Stat uses Open to resolve symlinks
func (m *mockMusicFS) Stat(name string) (fs.FileInfo, error) {
f, err := m.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return f.Stat()
}
// ReadDir uses Open to resolve symlinks
func (m *mockMusicFS) ReadDir(name string) ([]fs.DirEntry, error) {
f, err := m.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
if dirFile, ok := f.(fs.ReadDirFile); ok {
return dirFile.ReadDir(-1)
}
return nil, fmt.Errorf("not a directory")
return m.FS.Open(name)
}

View File

@@ -4,13 +4,11 @@ import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSubsonicApi(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Subsonic API Suite")

View File

@@ -108,12 +108,12 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
return addDefaultFilters(options)
}
func SongWithArtistTitle(artist, title string) Options {
func SongWithLyrics(artist, title string) Options {
return addDefaultFilters(Options{
Sort: "updated_at",
Order: "desc",
Max: 1,
Filters: And{Eq{"artist": artist, "title": title}},
Filters: And{Eq{"artist": artist, "title": title}, NotEq{"lyrics": ""}},
})
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
@@ -96,9 +95,9 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
artist, _ := p.String("artist")
title, _ := p.String("title")
response := newResponse()
lyricsResponse := responses.Lyrics{}
response.Lyrics = &lyricsResponse
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithArtistTitle(artist, title))
lyrics := responses.Lyrics{}
response.Lyrics = &lyrics
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithLyrics(artist, title))
if err != nil {
return nil, err
@@ -108,7 +107,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
return response, nil
}
structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0])
structuredLyrics, err := mediaFiles[0].StructuredLyrics()
if err != nil {
return nil, err
}
@@ -117,15 +116,15 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
return response, nil
}
lyricsResponse.Artist = artist
lyricsResponse.Title = title
lyrics.Artist = artist
lyrics.Title = title
lyricsText := ""
for _, line := range structuredLyrics[0].Line {
lyricsText += line.Value + "\n"
}
lyricsResponse.Value = lyricsText
lyrics.Value = lyricsText
return response, nil
}
@@ -141,13 +140,13 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
return nil, err
}
structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile)
lyrics, err := mediaFile.StructuredLyrics()
if err != nil {
return nil, err
}
response := newResponse()
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics)
response.LyricsList = buildLyricsList(mediaFile, lyrics)
return response, nil
}

View File

@@ -9,8 +9,6 @@ import (
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -34,8 +32,6 @@ var _ = Describe("MediaRetrievalController", func() {
artwork = &fakeArtwork{}
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"
})
Describe("GetCoverArt", func() {
@@ -113,22 +109,6 @@ var _ = Describe("MediaRetrievalController", func() {
Expect(response.Lyrics.Title).To(Equal(""))
Expect(response.Lyrics.Value).To(Equal(""))
})
It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() {
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
mockRepo.SetData(model.MediaFiles{
{
Path: "tests/fixtures/test.mp3",
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
},
})
response, err := router.GetLyrics(r)
Expect(err).To(BeNil())
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
})
})
Describe("getLyricsBySongId", func() {

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -1,6 +0,0 @@
[ar:Rick Astley]
[ti:That one song]
[offset:-100]
[lang:eng]
[00:18.80]We're no strangers to love
[00:22.801]You know the rules and so do I

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -1,2 +0,0 @@
We're no strangers to love
You know the rules and so do I

View File

Binary file not shown.

View File

Binary file not shown.

BIN
tests/fixtures/test.wv vendored
View File

Binary file not shown.

View File

@@ -185,6 +185,7 @@ const AlbumSongs = (props) => {
{...props}
hasBulkActions={true}
showDiscSubtitles={true}
showReleaseDivider={true}
contextAlwaysVisible={!isDesktop}
classes={{ row: classes.row }}
>

View File

@@ -231,6 +231,7 @@ export const AlbumContextMenu = (props) =>
sort: { field: 'album', order: 'ASC' },
filter: {
album_id: props.record.id,
release_date: props.releaseDate,
disc_number: props.discNumber,
missing: false,
},

View File

@@ -24,6 +24,7 @@ export const PlayButton = ({ record, size, className }) => {
sort: { field: 'album', order: 'ASC' },
filter: {
album_id: record.id,
release_date: record.releaseDate,
disc_number: record.discNumber,
},
})

View File

@@ -59,12 +59,59 @@ const useStyles = makeStyles({
},
})
const ReleaseRow = forwardRef(
({ record, onClick, colSpan, contextAlwaysVisible }, ref) => {
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const classes = useStyles({ isDesktop })
const translate = useTranslate()
const handlePlaySubset = (releaseDate) => () => {
onClick(releaseDate)
}
let releaseTitle = []
if (record.releaseDate) {
releaseTitle.push(translate('resources.album.fields.released'))
releaseTitle.push(formatFullDate(record.releaseDate))
if (record.catalogNum && isDesktop) {
releaseTitle.push('· Cat #')
releaseTitle.push(record.catalogNum)
}
}
return (
<TableRow
hover
ref={ref}
onClick={handlePlaySubset(record.releaseDate)}
className={classes.row}
>
<TableCell colSpan={colSpan}>
<Typography variant="h6" className={classes.subtitle}>
{releaseTitle.join(' ')}
</Typography>
</TableCell>
<TableCell>
<AlbumContextMenu
record={{ id: record.albumId }}
releaseDate={record.releaseDate}
showLove={false}
className={classes.contextMenu}
visible={contextAlwaysVisible}
/>
</TableCell>
</TableRow>
)
},
)
ReleaseRow.displayName = 'ReleaseRow'
const DiscSubtitleRow = forwardRef(
({ record, onClick, colSpan, contextAlwaysVisible }, ref) => {
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const classes = useStyles({ isDesktop })
const handlePlaySubset = (discNumber) => () => {
onClick(discNumber)
const handlePlaySubset = (releaseDate, discNumber) => () => {
onClick(releaseDate, discNumber)
}
let subtitle = []
@@ -79,7 +126,7 @@ const DiscSubtitleRow = forwardRef(
<TableRow
hover
ref={ref}
onClick={handlePlaySubset(record.discNumber)}
onClick={handlePlaySubset(record.releaseDate, record.discNumber)}
className={classes.row}
>
<TableCell colSpan={colSpan}>
@@ -92,6 +139,7 @@ const DiscSubtitleRow = forwardRef(
<AlbumContextMenu
record={{ id: record.albumId }}
discNumber={record.discNumber}
releaseDate={record.releaseDate}
showLove={false}
className={classes.contextMenu}
hideShare={true}
@@ -110,6 +158,7 @@ export const SongDatagridRow = ({
record,
children,
firstTracksOfDiscs,
firstTracksOfReleases,
contextAlwaysVisible,
onClickSubset,
className,
@@ -127,6 +176,7 @@ export const SongDatagridRow = ({
discs: [
{
albumId: record?.albumId,
releaseDate: record?.releaseDate,
discNumber: record?.discNumber,
},
],
@@ -159,6 +209,15 @@ export const SongDatagridRow = ({
const childCount = fields.length
return (
<>
{firstTracksOfReleases.has(record.id) && (
<ReleaseRow
ref={dragDiscRef}
record={record}
onClick={onClickSubset}
contextAlwaysVisible={contextAlwaysVisible}
colSpan={childCount + (rest.expand ? 1 : 0)}
/>
)}
{firstTracksOfDiscs.has(record.id) && (
<DiscSubtitleRow
ref={dragDiscRef}
@@ -185,6 +244,7 @@ SongDatagridRow.propTypes = {
record: PropTypes.object,
children: PropTypes.node,
firstTracksOfDiscs: PropTypes.instanceOf(Set),
firstTracksOfReleases: PropTypes.instanceOf(Set),
contextAlwaysVisible: PropTypes.bool,
onClickSubset: PropTypes.func,
}
@@ -196,16 +256,23 @@ SongDatagridRow.defaultProps = {
const SongDatagridBody = ({
contextAlwaysVisible,
showDiscSubtitles,
showReleaseDivider,
...rest
}) => {
const dispatch = useDispatch()
const { ids, data } = rest
const playSubset = useCallback(
(discNumber) => {
(releaseDate, discNumber) => {
let idsToPlay = []
if (discNumber !== undefined) {
idsToPlay = ids.filter((id) => data[id].discNumber === discNumber)
idsToPlay = ids.filter(
(id) =>
data[id].releaseDate === releaseDate &&
data[id].discNumber === discNumber,
)
} else {
idsToPlay = ids.filter((id) => data[id].releaseDate === releaseDate)
}
dispatch(
playTracks(
@@ -230,7 +297,8 @@ const SongDatagridBody = ({
foundSubtitle = foundSubtitle || data[id].discSubtitle
if (
acc.length === 0 ||
(last && data[id].discNumber !== data[last].discNumber)
(last && data[id].discNumber !== data[last].discNumber) ||
(last && data[id].releaseDate !== data[last].releaseDate)
) {
acc.push(id)
}
@@ -243,12 +311,37 @@ const SongDatagridBody = ({
return set
}, [ids, data, showDiscSubtitles])
const firstTracksOfReleases = useMemo(() => {
if (!ids) {
return new Set()
}
const set = new Set(
ids
.filter((i) => data[i])
.reduce((acc, id) => {
const last = acc && acc[acc.length - 1]
if (
acc.length === 0 ||
(last && data[id].releaseDate !== data[last].releaseDate)
) {
acc.push(id)
}
return acc
}, []),
)
if (!showReleaseDivider || set.size < 2) {
set.clear()
}
return set
}, [ids, data, showReleaseDivider])
return (
<PureDatagridBody
{...rest}
row={
<SongDatagridRow
firstTracksOfDiscs={firstTracksOfDiscs}
firstTracksOfReleases={firstTracksOfReleases}
contextAlwaysVisible={contextAlwaysVisible}
onClickSubset={playSubset}
/>
@@ -260,6 +353,7 @@ const SongDatagridBody = ({
export const SongDatagrid = ({
contextAlwaysVisible,
showDiscSubtitles,
showReleaseDivider,
...rest
}) => {
const classes = useStyles()
@@ -272,6 +366,7 @@ export const SongDatagrid = ({
<SongDatagridBody
contextAlwaysVisible={contextAlwaysVisible}
showDiscSubtitles={showDiscSubtitles}
showReleaseDivider={showReleaseDivider}
/>
}
/>
@@ -281,5 +376,6 @@ export const SongDatagrid = ({
SongDatagrid.propTypes = {
contextAlwaysVisible: PropTypes.bool,
showDiscSubtitles: PropTypes.bool,
showReleaseDivider: PropTypes.bool,
classes: PropTypes.object,
}

View File

@@ -75,7 +75,6 @@ export const SongInfo = (props) => {
compilation: <BooleanField source="compilation" />,
bitRate: <BitrateField source="bitRate" />,
bitDepth: <NumberField source="bitDepth" />,
sampleRate: <NumberField source="sampleRate" />,
channels: <NumberField source="channels" />,
size: <SizeField source="size" />,
updatedAt: <DateField source="updatedAt" showTime />,
@@ -93,14 +92,7 @@ export const SongInfo = (props) => {
roles.push([name, record.participants[name].length])
}
const optionalFields = [
'discSubtitle',
'comment',
'bpm',
'genre',
'bitDepth',
'sampleRate',
]
const optionalFields = ['discSubtitle', 'comment', 'bpm', 'genre', 'bitDepth']
optionalFields.forEach((field) => {
!record[field] && delete data[field]
})

View File

@@ -19,7 +19,6 @@
"updatedAt": "Updated at",
"bitRate": "Bit rate",
"bitDepth": "Bit depth",
"sampleRate": "Sample rate",
"channels": "Channels",
"discSubtitle": "Disc Subtitle",
"starred": "Favourite",