mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-31 19:08:06 -05:00
Compare commits
20 Commits
plugin-spi
...
codex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0f1ddecbe | ||
|
|
1e4e3eac6e | ||
|
|
19d443ec7f | ||
|
|
db92cf9e47 | ||
|
|
ec9f9aa243 | ||
|
|
0d1f2bcc8a | ||
|
|
dfa217ab51 | ||
|
|
3d6a2380bc | ||
|
|
53aa640f35 | ||
|
|
e4d65a7828 | ||
|
|
b41123f75e | ||
|
|
6f52c0201c | ||
|
|
4944f8035a | ||
|
|
0d5097d888 | ||
|
|
ed7ee3d9f8 | ||
|
|
74803bb43e | ||
|
|
0159cf73e2 | ||
|
|
ac1d51f9d0 | ||
|
|
91eb661db5 | ||
|
|
524d508916 |
110
AGENTS.md
Normal file
110
AGENTS.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Testing Instructions
|
||||
|
||||
- **No implementation task is considered complete until it includes thorough, passing tests that cover the new or
|
||||
changed functionality. All new code must be accompanied by Ginkgo/Gomega tests, and PRs/commits without tests should
|
||||
be considered incomplete.**
|
||||
- All Go tests in this project **MUST** be written using the **Ginkgo v2** and **Gomega** frameworks.
|
||||
- To run all tests, use `make test`.
|
||||
- To run tests for a specific package, use `make test PKG=./pkgname/...`
|
||||
- Do not run tests in parallel
|
||||
- Don't use `--fail-on-pending`
|
||||
|
||||
## Mocking Convention
|
||||
|
||||
- Always try to use the mocks provided in the `tests` package before creating a new mock implementation.
|
||||
- Only create a new mock if the required functionality is not covered by the existing mocks in `tests`.
|
||||
- Never mock a real implementation when testing. Remember: there is no value in testing an interface, only the real implementation.
|
||||
|
||||
## Example
|
||||
|
||||
Every package that you write tests for, should have a `*_suite_test.go` file, to hook up the Ginkgo test suite. Example:
|
||||
```
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestCore(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Core Suite")
|
||||
}
|
||||
```
|
||||
Never put a `func Test*` in regular *_test.go files, only in `*_suite_test.go` files.
|
||||
|
||||
Refer to existing test suites for examples of proper setup and usage, such as the one defined in @core_suite_test.go
|
||||
|
||||
## Exceptions
|
||||
|
||||
There should be no exceptions to this rule. If you encounter tests written with the standard `testing` package or other frameworks, they should be refactored to use Ginkgo/Gomega. If you need a new mock, first confirm that it does not already exist in the `tests` package.
|
||||
|
||||
### Configuration
|
||||
|
||||
You can set config values in the BeforeEach/BeforeAll blocks. If you do so, remember to add `DeferCleanup(configtest.SetupConfig())` to reset the values. Example:
|
||||
|
||||
```go
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableDownloads = true
|
||||
})
|
||||
```
|
||||
|
||||
# Logging System Usage Guide
|
||||
|
||||
This project uses a custom logging system built on top of logrus, `log/log.go`. Follow these conventions for all logging:
|
||||
|
||||
## Logging API
|
||||
- Use the provided functions for logging at different levels:
|
||||
- `Error(...)`, `Warn(...)`, `Info(...)`, `Debug(...)`, `Trace(...)`, `Fatal(...)`
|
||||
- These functions accept flexible arguments:
|
||||
- The first argument can be a context (`context.Context`), an HTTP request, or `nil`.
|
||||
- The next argument is the log message (string or error).
|
||||
- Additional arguments are key-value pairs (e.g., `"key", value`).
|
||||
- If the last argument is an error, it is logged under the `error` key.
|
||||
|
||||
**Examples:**
|
||||
```go
|
||||
log.Error("A message")
|
||||
log.Error(ctx, "A message with context")
|
||||
log.Error("Failed to save", "id", 123, err)
|
||||
log.Info(req, "Request received", "user", userID)
|
||||
```
|
||||
|
||||
## Logging errors
|
||||
- You don't need to add "err" key when logging an error, it is automatically added.
|
||||
- Error must always be the last parameter in the log call.
|
||||
Examples:
|
||||
```go
|
||||
log.Error("Failed to save", "id", 123, err) // GOOD
|
||||
log.Error("Failed to save", "id", 123, "err", err) // BAD
|
||||
log.Error("Failed to save", err, "id", 123) // BAD
|
||||
```
|
||||
|
||||
## Context and Request Logging
|
||||
- If a context or HTTP request is passed as the first argument, any logger fields in the context are included in the log entry.
|
||||
- Use `log.NewContext(ctx, "key", value, ...)` to add fields to a context for logging.
|
||||
|
||||
## Log Levels
|
||||
- Set the global log level with `log.SetLevel(log.LevelInfo)` or `log.SetLevelString("info")`.
|
||||
- Per-path log levels can be set with `log.SetLogLevels(map[string]string{"path": "level"})`.
|
||||
- Use `log.IsGreaterOrEqualTo(level)` to check if a log level is enabled for the current code path.
|
||||
|
||||
## Source Line Logging
|
||||
- Enable source file/line logging with `log.SetLogSourceLine(true)`.
|
||||
|
||||
## Best Practices
|
||||
- Always use the logging API, never log directly with logrus or fmt.
|
||||
- Prefer structured logging (key-value pairs) for important data.
|
||||
- Use context/request logging for traceability in web handlers.
|
||||
- For tests, use Ginkgo/Gomega and set up a test logger as in `log/log_test.go`.
|
||||
|
||||
## See Also
|
||||
- `log/log.go` for implementation details
|
||||
- `log/log_test.go` for usage examples and test patterns
|
||||
11
Makefile
11
Makefile
@@ -36,8 +36,9 @@ watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go tool ginkgo watch -tags=netgo -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
PKG ?= ./...
|
||||
test: ##@Development Run Go tests
|
||||
go test -tags netgo ./...
|
||||
go test -tags netgo $(PKG)
|
||||
.PHONY: test
|
||||
|
||||
testrace: ##@Development Run Go tests with race detector
|
||||
@@ -156,10 +157,10 @@ package: docker-build ##@Cross_Compilation Create binaries and packages for ALL
|
||||
get-music: ##@Development Download some free music from Navidrome's demo instance
|
||||
mkdir -p music
|
||||
( cd music; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=2Y3qQA6zJC3ObbBrF9ZBoV" > brock.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=04HrSORpypcLGNUdQp37gn" > back_on_earth.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=5xcMPJdeEgNrGtnzYbzAqb" > ugress.zip; \
|
||||
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=1jjQMAZrG3lUsJ0YH6ZRS0" > voodoocuts.zip; \
|
||||
for file in *.zip; do unzip -n $${file}; done )
|
||||
@echo "Done. Remember to set your MusicFolder to ./music"
|
||||
.PHONY: get-music
|
||||
|
||||
@@ -82,15 +82,15 @@ var _ = Describe("Extractor", func() {
|
||||
|
||||
// TabLib 1.12 returns 18, previous versions return 39.
|
||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 39, 40, 43, 49))
|
||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.HasPicture).To(BeFalse())
|
||||
Expect(m.HasPicture).To(BeTrue())
|
||||
})
|
||||
|
||||
DescribeTable("Format-Specific tests",
|
||||
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool) {
|
||||
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
|
||||
file = "tests/fixtures/" + file
|
||||
mds, err := e.Parse(file)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
@@ -98,7 +98,7 @@ var _ = Describe("Extractor", func() {
|
||||
|
||||
m := mds[file]
|
||||
|
||||
Expect(m.HasPicture).To(BeFalse())
|
||||
Expect(m.HasPicture).To(Equal(image))
|
||||
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
|
||||
Expect(m.AudioProperties.Channels).To(Equal(channels))
|
||||
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
|
||||
@@ -168,24 +168,24 @@ var _ = Describe("Extractor", func() {
|
||||
},
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
|
||||
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false),
|
||||
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
|
||||
|
||||
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false),
|
||||
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false),
|
||||
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false),
|
||||
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
||||
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
|
||||
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false),
|
||||
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
|
||||
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false),
|
||||
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, false),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
|
||||
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true),
|
||||
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
|
||||
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true),
|
||||
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
|
||||
)
|
||||
|
||||
// Skip these tests when running as root
|
||||
|
||||
@@ -225,18 +225,25 @@ char has_cover(const TagLib::FileRef f) {
|
||||
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
|
||||
hasCover = !opusFile->tag()->pictureList().isEmpty();
|
||||
}
|
||||
// ----- WMA
|
||||
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
|
||||
const TagLib::ASF::Tag *tag{asfFile->tag()};
|
||||
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
|
||||
}
|
||||
// ----- WAV
|
||||
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
|
||||
if (wavFile->hasID3v2Tag()) {
|
||||
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- AIFF
|
||||
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
|
||||
if (aiffFile->hasID3v2Tag()) {
|
||||
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
|
||||
hasCover = !frameListMap["APIC"].isEmpty();
|
||||
}
|
||||
}
|
||||
// ----- WMA
|
||||
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
|
||||
const TagLib::ASF::Tag *tag{ asfFile->tag() };
|
||||
hasCover = tag && asfFile->tag()->attributeListMap().contains("WM/Picture");
|
||||
}
|
||||
|
||||
return hasCover;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ type configOptions struct {
|
||||
PID pidOptions
|
||||
Inspect inspectOptions
|
||||
Subsonic subsonicOptions
|
||||
LyricsPriority string
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
@@ -109,7 +110,6 @@ type configOptions struct {
|
||||
DevActivityPanel bool
|
||||
DevActivityPanelUpdateRate time.Duration
|
||||
DevSidebarPlaylists bool
|
||||
DevEnableBufferedScrobble bool
|
||||
DevShowArtistPage bool
|
||||
DevOffsetOptimize int
|
||||
DevArtworkMaxRequests int
|
||||
@@ -132,6 +132,7 @@ 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 {
|
||||
@@ -312,6 +313,7 @@ func Load(noConfigDump bool) {
|
||||
}
|
||||
logDeprecatedOptions("Scanner.GenreSeparators")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
@@ -498,6 +500,7 @@ 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)
|
||||
@@ -528,6 +531,8 @@ func init() {
|
||||
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devenableprofiler", false)
|
||||
@@ -535,7 +540,6 @@ func init() {
|
||||
viper.SetDefault("devautologinusername", "")
|
||||
viper.SetDefault("devactivitypanel", true)
|
||||
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
|
||||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
|
||||
@@ -45,7 +45,7 @@ func createAgents(ds model.DataStore) *Agents {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, name)
|
||||
res = append(res, init(ds))
|
||||
res = append(res, agent)
|
||||
}
|
||||
log.Debug("List of agents enabled", "names", enabled)
|
||||
|
||||
|
||||
@@ -344,18 +344,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
55
core/common_test.go
Normal file
55
core/common_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
)
|
||||
|
||||
var _ = Describe("common.go", func() {
|
||||
Describe("userName", func() {
|
||||
It("returns the username from context", func() {
|
||||
ctx := request.WithUser(context.Background(), model.User{UserName: "testuser"})
|
||||
Expect(userName(ctx)).To(Equal("testuser"))
|
||||
})
|
||||
|
||||
It("returns 'UNKNOWN' if no user in context", func() {
|
||||
ctx := context.Background()
|
||||
Expect(userName(ctx)).To(Equal("UNKNOWN"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AbsolutePath", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
libId int
|
||||
path string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
libId = 1
|
||||
path = "music/file.mp3"
|
||||
mockLib := &tests.MockLibraryRepo{}
|
||||
mockLib.SetData(model.Libraries{{ID: libId, Path: "/library/root"}})
|
||||
ds.MockedLibrary = mockLib
|
||||
})
|
||||
|
||||
It("returns the absolute path when library exists", func() {
|
||||
ctx := context.Background()
|
||||
abs := AbsolutePath(ctx, ds, libId, path)
|
||||
Expect(abs).To(Equal("/library/root/music/file.mp3"))
|
||||
})
|
||||
|
||||
It("returns the original path if library not found", func() {
|
||||
ctx := context.Background()
|
||||
abs := AbsolutePath(ctx, ds, 999, path)
|
||||
Expect(abs).To(Equal(path))
|
||||
})
|
||||
})
|
||||
})
|
||||
37
core/lyrics/lyrics.go
Normal file
37
core/lyrics/lyrics.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
var lyricsList model.LyricList
|
||||
var err error
|
||||
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
lyricsList, err = fromEmbedded(ctx, mf)
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
lyricsList, err = fromExternalFile(ctx, mf, pattern)
|
||||
default:
|
||||
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
return lyricsList, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
17
core/lyrics/lyrics_suite_test.go
Normal file
17
core/lyrics/lyrics_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package lyrics_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestLyrics(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Lyrics Suite")
|
||||
}
|
||||
124
core/lyrics/lyrics_test.go
Normal file
124
core/lyrics/lyrics_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package lyrics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
const badLyrics = "This is a set of lyrics\nThat is not good"
|
||||
unsynced, _ := model.ToLyrics("xxx", badLyrics)
|
||||
embeddedLyrics := model.LyricList{*unsynced}
|
||||
|
||||
syncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
unsyncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
lyricsJson, _ := json.Marshal(embeddedLyrics)
|
||||
|
||||
mf = model.MediaFile{
|
||||
Lyrics: string(lyricsJson),
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
}
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
},
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
|
||||
|
||||
Context("Errors", func() {
|
||||
var RegularUserContext = XContext
|
||||
var isRegularUser = os.Getuid() != 0
|
||||
if isRegularUser {
|
||||
RegularUserContext = Context
|
||||
}
|
||||
|
||||
RegularUserContext("run without root permissions", func() {
|
||||
var accessForbiddenFile string
|
||||
|
||||
BeforeEach(func() {
|
||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||
|
||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mf.Path = accessForbiddenFile
|
||||
|
||||
DeferCleanup(func() {
|
||||
Expect(f.Close()).To(Succeed())
|
||||
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
It("should fallback to embedded if an error happens when parsing file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3,embedded"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
})
|
||||
|
||||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
51
core/lyrics/sources.go
Normal file
51
core/lyrics/sources.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
if mf.Lyrics != "" {
|
||||
log.Trace(ctx, "embedded lyrics found in file", "title", mf.Title)
|
||||
return mf.StructuredLyrics()
|
||||
}
|
||||
|
||||
log.Trace(ctx, "no embedded lyrics for file", "path", mf.Title)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
|
||||
basePath := mf.AbsolutePath()
|
||||
ext := path.Ext(basePath)
|
||||
|
||||
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
|
||||
|
||||
contents, err := os.ReadFile(externalLyric)
|
||||
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lyrics, err := model.ToLyrics("xxx", string(contents))
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
|
||||
return nil, err
|
||||
} else if lyrics == nil {
|
||||
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
|
||||
|
||||
return model.LyricList{*lyrics}, nil
|
||||
}
|
||||
112
core/lyrics/sources_test.go
Normal file
112
core/lyrics/sources_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
Describe("fromEmbedded", func() {
|
||||
It("should return nothing for a media file with no lyrics", func() {
|
||||
mf := model.MediaFile{}
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return lyrics for a media file with well-formatted lyrics", func() {
|
||||
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
||||
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
||||
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
|
||||
expectedList := model.LyricList{*synced, *unsynced}
|
||||
lyricsJson, err := json.Marshal(expectedList)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mf := model.MediaFile{
|
||||
Lyrics: string(lyricsJson),
|
||||
}
|
||||
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(Equal(expectedList))
|
||||
})
|
||||
|
||||
It("should return an error if somehow the JSON is bad", func() {
|
||||
mf := model.MediaFile{Lyrics: "["}
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromExternalFile", func() {
|
||||
It("should return nil for lyrics that don't exist", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return synchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should return unsynchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -61,9 +60,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, name)
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
s = newBufferedScrobbler(ds, s, name)
|
||||
}
|
||||
s = newBufferedScrobbler(ds, s, name)
|
||||
p.scrobblers[name] = s
|
||||
}
|
||||
log.Debug("List of scrobblers enabled", "names", enabled)
|
||||
@@ -183,11 +180,7 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile,
|
||||
if !s.IsAuthorized(ctx, u.ID) {
|
||||
continue
|
||||
}
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
} else {
|
||||
log.Debug(ctx, "Sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
}
|
||||
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
|
||||
err := s.Scrobble(ctx, u.ID, scrobble)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -27,9 +26,6 @@ var _ = Describe("PlayTracker", func() {
|
||||
var fake fakeScrobbler
|
||||
|
||||
BeforeEach(func() {
|
||||
// Remove buffering to simplify tests
|
||||
conf.Server.DevEnableBufferedScrobble = false
|
||||
|
||||
ctx = context.Background()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
@@ -42,6 +38,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
return nil
|
||||
})
|
||||
tracker = newPlayTracker(ds, events.GetBroker())
|
||||
tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests
|
||||
|
||||
track = model.MediaFile{
|
||||
ID: "123",
|
||||
|
||||
@@ -32,7 +32,7 @@ var (
|
||||
// Should either be at the beginning of file, or beginning of line
|
||||
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
|
||||
timeRegex = regexp.MustCompile(timeRegexString)
|
||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset):([^]]+)]`)
|
||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
|
||||
)
|
||||
|
||||
func (l Lyrics) IsEmpty() bool {
|
||||
@@ -72,6 +72,8 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
||||
switch idTag[1] {
|
||||
case "ar":
|
||||
artist = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
case "lang":
|
||||
language = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
case "offset":
|
||||
{
|
||||
off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64)
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
var _ = Describe("ToLyrics", func() {
|
||||
It("should parse tags with spaces", func() {
|
||||
num := int64(1551)
|
||||
lyrics, err := ToLyrics("xxx", "[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
|
||||
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Lang).To(Equal("eng"))
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.DisplayArtist).To(Equal("An artist"))
|
||||
Expect(lyrics.DisplayTitle).To(Equal("A title"))
|
||||
|
||||
@@ -315,7 +315,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error)
|
||||
// RefreshPlayCounts updates the play count and last play date annotations for all albums, based
|
||||
// on the media files associated with them.
|
||||
func (r *albumRepository) RefreshPlayCounts() (int64, error) {
|
||||
query := rawSQL(`
|
||||
query := Expr(`
|
||||
with play_counts as (
|
||||
select user_id, album_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
|
||||
from media_file
|
||||
|
||||
@@ -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 := rawSQL(`
|
||||
query := Expr(`
|
||||
with play_counts as (
|
||||
select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
|
||||
from media_file
|
||||
@@ -259,76 +259,123 @@ on conflict (user_id, item_id, item_type) do update
|
||||
return r.executeSQL(query)
|
||||
}
|
||||
|
||||
// RefreshStats updates the stats field for all artists, based on the media files associated with them.
|
||||
// BFR Maybe filter by "touched" artists?
|
||||
// RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time.
|
||||
// It processes artists in batches to handle potentially large updates.
|
||||
func (r *artistRepository) RefreshStats() (int64, error) {
|
||||
// First get all counters, one query groups by artist/role, and another with totals per artist.
|
||||
// Union both queries and group by artist to get a single row of counters per artist/role.
|
||||
// Then format the counters in a JSON object, one key for each role.
|
||||
// Finally update the artist table with the new counters
|
||||
// In all queries, atom is the artist ID and path is the role (or "total" for the totals)
|
||||
query := rawSQL(`
|
||||
-- CTE to get counters for each artist, grouped by role
|
||||
with artist_role_counters as (
|
||||
-- Get counters for each artist, grouped by role
|
||||
-- (remove the index from the role: composer[0] => composer
|
||||
select atom as artist_id,
|
||||
substr(
|
||||
replace(jt.path, '$.', ''),
|
||||
1,
|
||||
case when instr(replace(jt.path, '$.', ''), '[') > 0
|
||||
then instr(replace(jt.path, '$.', ''), '[') - 1
|
||||
else length(replace(jt.path, '$.', ''))
|
||||
end
|
||||
) as role,
|
||||
count(distinct album_id) as album_count,
|
||||
count(mf.id) as count,
|
||||
sum(size) as size
|
||||
from media_file mf
|
||||
left join json_tree(participants) jt
|
||||
where atom is not null and key = 'id'
|
||||
group by atom, role
|
||||
),
|
||||
touchedArtistsQuerySQL := `
|
||||
SELECT DISTINCT mfa.artist_id
|
||||
FROM media_file_artists mfa
|
||||
JOIN media_file mf ON mfa.media_file_id = mf.id
|
||||
WHERE mf.updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1)
|
||||
`
|
||||
|
||||
-- CTE to get the totals for each artist
|
||||
artist_total_counters as (
|
||||
select mfa.artist_id,
|
||||
'total' as role,
|
||||
count(distinct mf.album_id) as album_count,
|
||||
count(distinct mf.id) as count,
|
||||
sum(mf.size) as size
|
||||
from (select artist_id, media_file_id
|
||||
from main.media_file_artists) as mfa
|
||||
join main.media_file mf on mfa.media_file_id = mf.id
|
||||
group by mfa.artist_id
|
||||
),
|
||||
var allTouchedArtistIDs []string
|
||||
if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil {
|
||||
return 0, fmt.Errorf("fetching touched artist IDs: %w", err)
|
||||
}
|
||||
|
||||
-- CTE to combine role and total counters
|
||||
combined_counters as (
|
||||
select artist_id, role, album_count, count, size
|
||||
from artist_role_counters
|
||||
union
|
||||
select artist_id, role, album_count, count, size
|
||||
from artist_total_counters
|
||||
),
|
||||
if len(allTouchedArtistIDs) == 0 {
|
||||
log.Debug(r.ctx, "RefreshStats: No artists to update.")
|
||||
return 0, nil
|
||||
}
|
||||
log.Debug(r.ctx, "RefreshStats: Found artists to update.", "count", len(allTouchedArtistIDs))
|
||||
|
||||
-- CTE to format the counters in a JSON object
|
||||
artist_counters as (
|
||||
select artist_id as id,
|
||||
json_group_object(
|
||||
replace(role, '"', ''),
|
||||
json_object('a', album_count, 'm', count, 's', size)
|
||||
) as counters
|
||||
from combined_counters
|
||||
group by artist_id
|
||||
)
|
||||
// Template for the batch update with placeholder markers that we'll replace
|
||||
batchUpdateStatsSQL := `
|
||||
WITH artist_role_counters AS (
|
||||
SELECT jt.atom AS artist_id,
|
||||
substr(
|
||||
replace(jt.path, '$.', ''),
|
||||
1,
|
||||
CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0
|
||||
THEN instr(replace(jt.path, '$.', ''), '[') - 1
|
||||
ELSE length(replace(jt.path, '$.', ''))
|
||||
END
|
||||
) AS role,
|
||||
count(DISTINCT mf.album_id) AS album_count,
|
||||
count(mf.id) AS count,
|
||||
sum(mf.size) AS size
|
||||
FROM media_file mf
|
||||
JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
|
||||
WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
|
||||
GROUP BY jt.atom, role
|
||||
),
|
||||
artist_total_counters AS (
|
||||
SELECT mfa.artist_id,
|
||||
'total' AS role,
|
||||
count(DISTINCT mf.album_id) AS album_count,
|
||||
count(DISTINCT mf.id) AS count,
|
||||
sum(mf.size) AS size
|
||||
FROM media_file_artists mfa
|
||||
JOIN media_file mf ON mfa.media_file_id = mf.id
|
||||
WHERE mfa.artist_id IN (TOTAL_IDS_PLACEHOLDER) -- Will replace with actual placeholders
|
||||
GROUP BY mfa.artist_id
|
||||
),
|
||||
combined_counters AS (
|
||||
SELECT artist_id, role, album_count, count, size FROM artist_role_counters
|
||||
UNION
|
||||
SELECT artist_id, role, album_count, count, size FROM artist_total_counters
|
||||
),
|
||||
artist_counters AS (
|
||||
SELECT artist_id AS id,
|
||||
json_group_object(
|
||||
replace(role, '"', ''),
|
||||
json_object('a', album_count, 'm', count, 's', size)
|
||||
) AS counters
|
||||
FROM combined_counters
|
||||
GROUP BY artist_id
|
||||
)
|
||||
UPDATE artist
|
||||
SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'),
|
||||
updated_at = datetime(current_timestamp, 'localtime')
|
||||
WHERE artist.id IN (UPDATE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders
|
||||
|
||||
-- Update the artist table with the new counters
|
||||
update artist
|
||||
set stats = coalesce((select counters from artist_counters where artist_counters.id = artist.id), '{}'),
|
||||
updated_at = datetime(current_timestamp, 'localtime')
|
||||
where id <> ''; -- always true, to avoid warnings`)
|
||||
return r.executeSQL(query)
|
||||
var totalRowsAffected int64 = 0
|
||||
const batchSize = 1000
|
||||
|
||||
batchCounter := 0
|
||||
for artistIDBatch := range slice.CollectChunks(slices.Values(allTouchedArtistIDs), batchSize) {
|
||||
batchCounter++
|
||||
log.Trace(r.ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch))
|
||||
|
||||
// Create placeholders for each ID in the IN clauses
|
||||
placeholders := make([]string, len(artistIDBatch))
|
||||
for i := range artistIDBatch {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
// Don't add extra parentheses, the IN clause already expects them in SQL syntax
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
|
||||
// Replace the placeholder markers with actual SQL placeholders
|
||||
batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 1)
|
||||
batchSQL = strings.Replace(batchSQL, "TOTAL_IDS_PLACEHOLDER", inClause, 1)
|
||||
batchSQL = strings.Replace(batchSQL, "UPDATE_IDS_PLACEHOLDER", inClause, 1)
|
||||
|
||||
// Create a single parameter array with all IDs (repeated 3 times for each IN clause)
|
||||
// We need to repeat each ID 3 times (once for each IN clause)
|
||||
var args []interface{}
|
||||
for _, id := range artistIDBatch {
|
||||
args = append(args, id) // For ROLE_IDS_PLACEHOLDER
|
||||
}
|
||||
for _, id := range artistIDBatch {
|
||||
args = append(args, id) // For TOTAL_IDS_PLACEHOLDER
|
||||
}
|
||||
for _, id := range artistIDBatch {
|
||||
args = append(args, id) // For UPDATE_IDS_PLACEHOLDER
|
||||
}
|
||||
|
||||
// Now use Expr with the expanded SQL and all parameters
|
||||
sqlizer := Expr(batchSQL, args...)
|
||||
|
||||
rowsAffected, err := r.executeSQL(sqlizer)
|
||||
if err != nil {
|
||||
return totalRowsAffected, fmt.Errorf("executing batch update for artist stats (batch %d): %w", batchCounter, err)
|
||||
}
|
||||
totalRowsAffected += rowsAffected
|
||||
}
|
||||
|
||||
log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected)
|
||||
return totalRowsAffected, nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) {
|
||||
|
||||
@@ -57,14 +57,6 @@ func toCamelCase(str string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// rawSQL is a string that will be used as is in the SQL query executor
|
||||
// It does not support arguments
|
||||
type rawSQL string
|
||||
|
||||
func (r rawSQL) ToSql() (string, []interface{}, error) {
|
||||
return string(r), nil, nil
|
||||
}
|
||||
|
||||
func Exists(subTable string, cond squirrel.Sqlizer) existsCond {
|
||||
return existsCond{subTable: subTable, cond: cond, not: false}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func (r *libraryRepository) ScanEnd(id int) error {
|
||||
return err
|
||||
}
|
||||
// https://www.sqlite.org/pragma.html#pragma_optimize
|
||||
_, err = r.executeSQL(rawSQL("PRAGMA optimize=0x10012;"))
|
||||
_, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;"))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album": "order_album_name, release_date, disc_number, track_number, order_artist_name, title",
|
||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
@@ -87,11 +87,12 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
|
||||
|
||||
var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
filters := map[string]filterFunc{
|
||||
"id": idFilter("media_file"),
|
||||
"title": fullTextFilter("media_file"),
|
||||
"starred": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"id": idFilter("media_file"),
|
||||
"title": fullTextFilter("media_file"),
|
||||
"starred": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"artists_id": artistFilter,
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.TagMappings() {
|
||||
@@ -242,7 +243,7 @@ func (r *mediaFileRepository) MarkMissingByFolder(missing bool, folderIDs ...str
|
||||
|
||||
// GetMissingAndMatching returns all mediafiles that are missing and their potential matches (comparing PIDs)
|
||||
// that were added/updated after the last scan started. The result is ordered by PID.
|
||||
// It does not need to load bookmarks, annotations and participnts, as they are not used by the scanner.
|
||||
// It does not need to load bookmarks, annotations and participants, as they are not used by the scanner.
|
||||
func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
|
||||
subQ := r.newSelect().Columns("pid").
|
||||
Where(And{
|
||||
|
||||
@@ -60,7 +60,7 @@ where tag.id = updated_values.id;
|
||||
`
|
||||
for _, table := range []string{"album", "media_file"} {
|
||||
start := time.Now()
|
||||
query := rawSQL(fmt.Sprintf(template, table))
|
||||
query := Expr(fmt.Sprintf(template, table))
|
||||
c, err := r.executeSQL(query)
|
||||
log.Debug(r.ctx, "Updated tag counts", "table", table, "elapsed", time.Since(start), "updated", c)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"size": "Mida del fitxer",
|
||||
"updatedAt": "Actualitzat",
|
||||
"bitRate": "Taxa de bits",
|
||||
"bitDepth": "Bits",
|
||||
"sampleRate": "Freqüencia de mostreig",
|
||||
"channels": "Canals",
|
||||
"discSubtitle": "Subtítol del disc",
|
||||
"starred": "Preferit",
|
||||
"comment": "Comentari",
|
||||
@@ -25,8 +28,13 @@
|
||||
"quality": "Qualitat",
|
||||
"bpm": "tempo",
|
||||
"playDate": "Darrer resproduït",
|
||||
"channels": "Canals",
|
||||
"createdAt": ""
|
||||
"createdAt": "Creat el",
|
||||
"grouping": "Agrupació",
|
||||
"mood": "Sentiment",
|
||||
"participants": "Participants",
|
||||
"tags": "Etiquetes",
|
||||
"mappedTags": "Etiquetes assignades",
|
||||
"rawTags": "Etiquetes sense processar"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Reprodueix després",
|
||||
@@ -46,6 +54,7 @@
|
||||
"duration": "Durada",
|
||||
"songCount": "Cançons",
|
||||
"playCount": "Reproduccions",
|
||||
"size": "Mida",
|
||||
"name": "Nom",
|
||||
"genre": "Gènere",
|
||||
"compilation": "Compilació",
|
||||
@@ -53,22 +62,28 @@
|
||||
"updatedAt": "Actualitzat ",
|
||||
"comment": "Comentari",
|
||||
"rating": "Valoració",
|
||||
"createdAt": "",
|
||||
"size": "",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": ""
|
||||
"createdAt": "Creat el",
|
||||
"size": "Mida",
|
||||
"originalDate": "Original",
|
||||
"releaseDate": "Publicat",
|
||||
"releases": "LLançament |||| Llançaments",
|
||||
"released": "Publicat",
|
||||
"recordLabel": "Discogràfica",
|
||||
"catalogNum": "Número de catàleg",
|
||||
"releaseType": "Tipus de publicació",
|
||||
"grouping": "Agrupació",
|
||||
"media": "Mitjà",
|
||||
"mood": "Sentiment"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Reprodueix",
|
||||
"playNext": "Reprodueix la següent",
|
||||
"addToQueue": "Reprodueix després",
|
||||
"share": "Compartir",
|
||||
"shuffle": "Aleatori",
|
||||
"addToPlaylist": "Afegeix a la llista",
|
||||
"download": "Descarrega",
|
||||
"info": "Obtén informació",
|
||||
"share": ""
|
||||
"info": "Obtén informació"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Tot",
|
||||
@@ -85,11 +100,27 @@
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"albumCount": "Nombre d'àlbums",
|
||||
"songCount": "Compte de cançons",
|
||||
"songCount": "Nombre de cançons",
|
||||
"size": "Mida",
|
||||
"playCount": "Reproduccions",
|
||||
"rating": "Valoració",
|
||||
"genre": "Gènere",
|
||||
"size": ""
|
||||
"role": "Rol"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum",
|
||||
"artist": "Artista |||| Artistes",
|
||||
"composer": "Compositor |||| Compositors",
|
||||
"conductor": "Conductor |||| Conductors",
|
||||
"lyricist": "Lletrista |||| Lletristes",
|
||||
"arranger": "Arranjador |||| Arranjadors",
|
||||
"producer": "Productor |||| Productors",
|
||||
"director": "Director |||| Directors",
|
||||
"engineer": "Enginyer |||| Enginyers",
|
||||
"mixer": "Mesclador |||| Mescladors",
|
||||
"remixer": "Remesclador |||| Remescladors",
|
||||
"djmixer": "DJ Mesclador |||| DJ Mescladors",
|
||||
"performer": "Intèrpret |||| Intèrprets"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -98,6 +129,7 @@
|
||||
"userName": "Nom d'usuari",
|
||||
"isAdmin": "És admin",
|
||||
"lastLoginAt": "Última connexió",
|
||||
"lastAccessAt": "Últim Accés",
|
||||
"updatedAt": "Actualitzat",
|
||||
"name": "Nom",
|
||||
"password": "Contrasenya",
|
||||
@@ -169,36 +201,53 @@
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "",
|
||||
"name": "Ràdio |||| Ràdios",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"streamUrl": "",
|
||||
"homePageUrl": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": ""
|
||||
"name": "Nom",
|
||||
"streamUrl": "URL del flux",
|
||||
"homePageUrl": "URL principal",
|
||||
"updatedAt": "Actualitzat",
|
||||
"createdAt": "Creat"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": ""
|
||||
"playNow": "Reprodueix"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "",
|
||||
"name": "Compartir |||| Compartits",
|
||||
"fields": {
|
||||
"username": "",
|
||||
"url": "",
|
||||
"description": "",
|
||||
"contents": "",
|
||||
"expiresAt": "",
|
||||
"lastVisitedAt": "",
|
||||
"visitCount": "",
|
||||
"format": "",
|
||||
"maxBitRate": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"downloadable": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"username": "Compartit per",
|
||||
"url": "URL",
|
||||
"description": "Descripció",
|
||||
"downloadable": "Permet descarregar?",
|
||||
"contents": "Continguts",
|
||||
"expiresAt": "Caduca",
|
||||
"lastVisitedAt": "Última Visita",
|
||||
"visitCount": "Visites",
|
||||
"format": "Format",
|
||||
"maxBitRate": "Taxa de bits màx.",
|
||||
"updatedAt": "Actualitzat",
|
||||
"createdAt": "Creat"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Fitxer faltant |||| Fitxers Faltants",
|
||||
"empty": "No falten fitxers",
|
||||
"fields": {
|
||||
"path": "Directori",
|
||||
"size": "Mida",
|
||||
"updatedAt": "Desaparegut"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Eliminar"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Fitxers faltants eliminats"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Gràcies d'haver instal·lat Navidrome!",
|
||||
@@ -211,28 +260,30 @@
|
||||
"password": "Contrasenya",
|
||||
"sign_in": "Inicia sessió",
|
||||
"sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar",
|
||||
"logout": "Sortida"
|
||||
"logout": "Sortida",
|
||||
"insightsCollectionNote": "Navidrome recull dades d'us anonimitzades per\najudar a millorar el projecte. Clica [aquí] per a saber-ne\nmés i no participar-hi si no vols"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Si us plau, useu solament lletres i nombres",
|
||||
"invalidChars": "Si us plau, useu només lletres i nombres",
|
||||
"passwordDoesNotMatch": "Les contrasenyes no coincideixen",
|
||||
"required": "Obligatori",
|
||||
"minLength": "Ha de tenir, si més no, %{min} caràcters",
|
||||
"maxLength": "Ha de tenir %{max} caràcter o menys",
|
||||
"minValue": "Ha de ser si més no %{min}",
|
||||
"maxLength": "Ha de tenir %{max} caràcters o menys",
|
||||
"minValue": "Ha de ser com a mínim %{min}",
|
||||
"maxValue": "Ha de ser %{max} o menys",
|
||||
"number": "Ha de ser un nombre",
|
||||
"email": "Ha de ser un correu vàlid",
|
||||
"oneOf": "Ha de ser un de: %{options}",
|
||||
"regex": "Ha de tenir el format (regexp): %{pattern}",
|
||||
"unique": "Ha de ser únic",
|
||||
"url": ""
|
||||
"url": "Ha de ser una URL vàlida"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Afegeix un filtre",
|
||||
"add": "Afegeix",
|
||||
"back": "Enrere",
|
||||
"bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Cancel·la",
|
||||
"clear_input_value": "Neteja el valor",
|
||||
"clone": "Clona",
|
||||
@@ -256,9 +307,8 @@
|
||||
"close_menu": "Tanca el menú",
|
||||
"unselect": "Anul·la la selecció",
|
||||
"skip": "Omet",
|
||||
"bulk_actions_mobile": "",
|
||||
"share": "",
|
||||
"download": ""
|
||||
"share": "Compartir",
|
||||
"download": "Descarregar"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Sí",
|
||||
@@ -334,7 +384,7 @@
|
||||
"i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat",
|
||||
"canceled": "Acció cancel·lada",
|
||||
"logged_out": "La sessió ha acabat, si us plau reconnecteu",
|
||||
"new_version": "Hi ha una versió nova disponible! Si us plau refresqueu aquesta finestra."
|
||||
"new_version": "Hi ha una versió nova disponible! Si us plau actualitzeu aquesta finestra."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Columnes a mostrar",
|
||||
@@ -351,29 +401,31 @@
|
||||
"noPlaylistsAvailable": "No n'hi ha cap disponible",
|
||||
"delete_user_title": "Esborra usuari '%{nom}'",
|
||||
"delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)",
|
||||
"remove_missing_title": "Eliminar fitxers faltants",
|
||||
"remove_missing_content": "Segur que vols eliminar els fitxers faltants seleccionats de la base de dades? Això eliminarà permanentment les referències a ells, incloent-hi el nombre de reproduccions i les valoracions.",
|
||||
"notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador",
|
||||
"notifications_not_available": "El navegador no suporta les notificacions o no heu connectat a Navidrome per https",
|
||||
"lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment",
|
||||
"lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm",
|
||||
"lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment",
|
||||
"lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm",
|
||||
"listenBrainzLinkSuccess": "Connectat correctament a ListenBrainz i seguiment activat com a: %{user}",
|
||||
"listenBrainzLinkFailure": "No s'ha pogut connectar a ListenBrainz: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz desconnectat i seguiment desactivat",
|
||||
"listenBrainzUnlinkFailure": "No s'ha pogut desconnectar de ListenBrainz",
|
||||
"openIn": {
|
||||
"lastfm": "Obri en Last.fm",
|
||||
"musicbrainz": "Obri en MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Llegeix més...",
|
||||
"listenBrainzLinkSuccess": "Ha reexit la vinculació amb ListenBrainz i se n'ha activat el seguiment com a usuari: %{user}",
|
||||
"listenBrainzLinkFailure": "No ha estat possible vincular-se a ListenBrainz: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "Desvinculat de ListenBrainz i desactivat el seguiment",
|
||||
"listenBrainzUnlinkFailure": "No s'ha pogut desvincular de ListenBrainz",
|
||||
"downloadOriginalFormat": "",
|
||||
"shareOriginalFormat": "",
|
||||
"shareDialogTitle": "",
|
||||
"shareBatchDialogTitle": "",
|
||||
"shareSuccess": "",
|
||||
"shareFailure": "",
|
||||
"downloadDialogTitle": "",
|
||||
"shareCopyToClipboard": ""
|
||||
"shareOriginalFormat": "Compartir en format original",
|
||||
"shareDialogTitle": "Compartir %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Copiar al porta-retalls: Ctrl+C, Enter",
|
||||
"shareSuccess": "URL copiada al porta-retalls: %{url}",
|
||||
"shareFailure": "Error copiant URL %{url} al porta-retalls",
|
||||
"downloadDialogTitle": "Deascarregar %{resource} '%{name}' (%{size})",
|
||||
"downloadOriginalFormat": "Descarregar en format original"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Discoteca",
|
||||
@@ -387,14 +439,15 @@
|
||||
"language": "Llengua",
|
||||
"defaultView": "Vista per defecte",
|
||||
"desktop_notifications": "Notificacions d'escriptori",
|
||||
"lastfmNotConfigured": "No s'ha configurat l'API de Last.fm",
|
||||
"lastfmScrobbling": "Activa el seguiment de Last.fm",
|
||||
"listenBrainzScrobbling": "Activa el seguiment de ListenBrainz",
|
||||
"replaygain": "",
|
||||
"preAmp": "",
|
||||
"replaygain": "Mode ReplayGain",
|
||||
"preAmp": "PreAmp de ReplayGain (dB)",
|
||||
"gain": {
|
||||
"none": "",
|
||||
"album": "",
|
||||
"track": ""
|
||||
"none": "Cap",
|
||||
"album": "Guany de l'àlbum",
|
||||
"track": "Guany de la pista"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -432,7 +485,12 @@
|
||||
"links": {
|
||||
"homepage": "Inici",
|
||||
"source": "Codi font",
|
||||
"featureRequests": "Sol·licitud de funcionalitats"
|
||||
"featureRequests": "Sol·licitud de funcionalitats",
|
||||
"lastInsightsCollection": "Última recolecció d'informació",
|
||||
"insights": {
|
||||
"disabled": "Desactivada",
|
||||
"waiting": "Esperant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -454,7 +512,7 @@
|
||||
"vol_up": "Apuja el volum",
|
||||
"vol_down": "Abaixa el volum",
|
||||
"toggle_love": "Afegeix la pista a favorits",
|
||||
"current_song": ""
|
||||
"current_song": "Anar a la cançó actual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,460 +1,518 @@
|
||||
{
|
||||
"languageName": "한국어",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "노래 |||| 노래들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"duration": "시간",
|
||||
"trackNumber": "#",
|
||||
"playCount": "재생 횟수",
|
||||
"title": "제목",
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"size": "파일 크기",
|
||||
"updatedAt": "업데이트됨",
|
||||
"bitRate": "비트레이트",
|
||||
"discSubtitle": "디스크 서브타이틀",
|
||||
"starred": "즐겨찾기",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"quality": "품질",
|
||||
"bpm": "BPM",
|
||||
"playDate": "마지막 재생",
|
||||
"channels": "채널",
|
||||
"createdAt": "추가된 날짜"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "나중에 재생",
|
||||
"playNow": "지금 재생",
|
||||
"addToPlaylist": "재생목록에 추가",
|
||||
"shuffleAll": "모든 노래 셔플",
|
||||
"download": "다운로드",
|
||||
"playNext": "다음 재생",
|
||||
"info": "정보"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "앨범 |||| 앨범들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"artist": "아티스트",
|
||||
"duration": "시간",
|
||||
"songCount": "노래",
|
||||
"playCount": "재생 횟수",
|
||||
"name": "이름",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"updatedAt": "업데이트됨",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"createdAt": "추가된 날짜",
|
||||
"size": "크기",
|
||||
"originalDate": "오리지널",
|
||||
"releaseDate": "발매일",
|
||||
"releases": "발매 음반 |||| 발매 음반들",
|
||||
"released": "발매됨"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "재생",
|
||||
"playNext": "다음에 재생",
|
||||
"addToQueue": "나중에 재생",
|
||||
"shuffle": "셔플",
|
||||
"addToPlaylist": "재생목록 추가",
|
||||
"download": "다운로드",
|
||||
"info": "정보",
|
||||
"share": "공유"
|
||||
},
|
||||
"lists": {
|
||||
"all": "모두",
|
||||
"random": "랜덤",
|
||||
"recentlyAdded": "최근 추가됨",
|
||||
"recentlyPlayed": "최근 재생됨",
|
||||
"mostPlayed": "가장 많이 재생됨",
|
||||
"starred": "즐겨찾기",
|
||||
"topRated": "높은 평가"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "아티스트 |||| 아티스트들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"albumCount": "앨범 수",
|
||||
"songCount": "노래 수",
|
||||
"playCount": "재생 횟수",
|
||||
"rating": "평가",
|
||||
"genre": "장르",
|
||||
"size": "크기"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "사용자 |||| 사용자들",
|
||||
"fields": {
|
||||
"userName": "사용자이름",
|
||||
"isAdmin": "관리자",
|
||||
"lastLoginAt": "마지막 로그인",
|
||||
"updatedAt": "업데이트됨",
|
||||
"name": "이름",
|
||||
"password": "비밀번호",
|
||||
"createdAt": "생성됨",
|
||||
"changePassword": "비밀번호를 변경할까요?",
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"newPassword": "새 비밀번호",
|
||||
"token": "토큰"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "이름 변경 사항은 다음 로그인 이후에 반영됨"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "사용자 생성됨",
|
||||
"updated": "사용자 업데이트됨",
|
||||
"deleted": "사용자 삭제됨"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "플레이어 |||| 플레이어들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"transcodingId": "트랜스코딩",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"client": "클라이언트",
|
||||
"userName": "사용자이름",
|
||||
"lastSeen": "마지막으로 봤음",
|
||||
"reportRealPath": "실제 경로 보고서",
|
||||
"scrobbleEnabled": "외부 서비스에 스크로블 보내기"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "트랜스코딩 |||| 트랜스코딩들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"targetFormat": "대상 포맷",
|
||||
"defaultBitRate": "기본 비트레이트",
|
||||
"command": "명령"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "재생목록 |||| 재생목록들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"duration": "지속",
|
||||
"ownerName": "소유자",
|
||||
"public": "공개",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"songCount": "노래",
|
||||
"comment": "댓글",
|
||||
"sync": "자동 가져오기",
|
||||
"path": "다음에서 가져오기"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "재생목록 선택:",
|
||||
"addNewPlaylist": "\"%{name}\" 만들기",
|
||||
"export": "내보내기",
|
||||
"makePublic": "공개",
|
||||
"makePrivate": "비공개"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "중복된 노래 추가",
|
||||
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "라디오 |||| 라디오들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"streamUrl": "스트리밍 URL",
|
||||
"homePageUrl": "홈페이지 URL",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "지금 재생"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "공유 |||| 공유되는 것들",
|
||||
"fields": {
|
||||
"username": "공유됨",
|
||||
"url": "URL",
|
||||
"description": "설명",
|
||||
"contents": "컨텐츠",
|
||||
"expiresAt": "만료",
|
||||
"lastVisitedAt": "마지막 방문",
|
||||
"visitCount": "방문 수",
|
||||
"format": "포맷",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"downloadable": "다운로드를 허용할까요?"
|
||||
}
|
||||
}
|
||||
"languageName": "한국어",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "노래 |||| 노래들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"duration": "시간",
|
||||
"trackNumber": "#",
|
||||
"playCount": "재생 횟수",
|
||||
"title": "제목",
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"size": "파일 크기",
|
||||
"updatedAt": "업데이트됨",
|
||||
"bitRate": "비트레이트",
|
||||
"bitDepth": "비트 심도",
|
||||
"sampleRate": "샘플레이트",
|
||||
"channels": "채널",
|
||||
"discSubtitle": "디스크 서브타이틀",
|
||||
"starred": "즐겨찾기",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"quality": "품질",
|
||||
"bpm": "BPM",
|
||||
"playDate": "마지막 재생",
|
||||
"createdAt": "추가된 날짜",
|
||||
"grouping": "그룹",
|
||||
"mood": "분위기",
|
||||
"participants": "추가 참가자",
|
||||
"tags": "추가 태그",
|
||||
"mappedTags": "매핑된 태그",
|
||||
"rawTags": "원시 태그"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "나중에 재생",
|
||||
"playNow": "지금 재생",
|
||||
"addToPlaylist": "재생목록에 추가",
|
||||
"shuffleAll": "모든 노래 셔플",
|
||||
"download": "다운로드",
|
||||
"playNext": "다음 재생",
|
||||
"info": "정보 얻기"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Navidrome을 설치해 주셔서 감사합니다!",
|
||||
"welcome2": "관리자를 만들고 시작해 보세요",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"buttonCreateAdmin": "관리자 만들기",
|
||||
"auth_check_error": "계속하려면 로그인하세요",
|
||||
"user_menu": "프로파일",
|
||||
"username": "사용자이름",
|
||||
"password": "비밀번호",
|
||||
"sign_in": "가입",
|
||||
"sign_in_error": "인증에 실패했습니다. 다시 시도하세요",
|
||||
"logout": "로그아웃"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "문자와 숫자만 사용하세요",
|
||||
"passwordDoesNotMatch": "비밀번호가 일치하지 않음",
|
||||
"required": "필수 항목임",
|
||||
"minLength": "%{min}자 이하여야 함",
|
||||
"maxLength": "%{max}자 이하여야 함",
|
||||
"minValue": "%{min}자 이상이어야 함",
|
||||
"maxValue": "%{max}자 이하여야 함",
|
||||
"number": "숫자여야 함",
|
||||
"email": "유효한 이메일이어야 함",
|
||||
"oneOf": "다음 중 하나여야 함: %{options}",
|
||||
"regex": "특정 형식(정규식)과 일치해야 함: %{pattern}",
|
||||
"unique": "고유해야 함",
|
||||
"url": "유효한 URL이어야 함"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "필터 추가",
|
||||
"add": "추가",
|
||||
"back": "뒤로 가기",
|
||||
"bulk_actions": "1 개 항목이 선택되었음 |||| %{smart_count} 개 항목이 선택되었음",
|
||||
"cancel": "취소",
|
||||
"clear_input_value": "값 지우기",
|
||||
"clone": "복제",
|
||||
"confirm": "확인",
|
||||
"create": "만들기",
|
||||
"delete": "삭제",
|
||||
"edit": "편집",
|
||||
"export": "내보내기",
|
||||
"list": "목록",
|
||||
"refresh": "새로 고침",
|
||||
"remove_filter": "이 필터 제거",
|
||||
"remove": "제거",
|
||||
"save": "저장",
|
||||
"search": "검색",
|
||||
"show": "표시",
|
||||
"sort": "정렬",
|
||||
"undo": "실행 취소",
|
||||
"expand": "확장",
|
||||
"close": "닫기",
|
||||
"open_menu": "메뉴 열기",
|
||||
"close_menu": "메뉴 닫기",
|
||||
"unselect": "선택 해제",
|
||||
"skip": "건너뛰기",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "공유",
|
||||
"download": "다운로드"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "예",
|
||||
"false": "아니요"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} 만들기",
|
||||
"dashboard": "대시보드",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "문제가 발생하였음",
|
||||
"list": "%{name}",
|
||||
"loading": "로딩 중",
|
||||
"not_found": "찾을 수 없음",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "아직 %{name}이(가) 없습니다.",
|
||||
"invite": "추가할까요?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "업로드할 파일을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 파일을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "업로드할 사진을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 사진을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "참조 데이터를 찾을 수 없습니다.",
|
||||
"many_missing": "연관된 참조 중 적어도 하나는 더 이상 사용할 수 없는 것 같습니다.",
|
||||
"single_missing": "연관된 참조는 더 이상 사용할 수 없는 것 같습니다."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "비밀번호 숨기기",
|
||||
"toggle_hidden": "비밀번호 표시"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "정보",
|
||||
"are_you_sure": "확실한가요?",
|
||||
"bulk_delete_content": "이 %{name}을(를) 삭제할까요? |||| 이 %{smart_count} 개의 항목을 삭제할까요?",
|
||||
"bulk_delete_title": "%{name} 삭제 |||| %{smart_count} %{name} 삭제",
|
||||
"delete_content": "이 항목을 삭제할까요?",
|
||||
"delete_title": "%{name} #%{id} 삭제",
|
||||
"details": "상세 정보",
|
||||
"error": "클라이언트 오류가 발생하여 요청을 완료할 수 없습니다.",
|
||||
"invalid_form": "양식이 유효하지 않습니다. 오류를 확인하세요",
|
||||
"loading": "페이지가 로드 중입니다. 잠시만 기다려 주세요",
|
||||
"no": "아니요",
|
||||
"not_found": "잘못된 URL을 입력했거나 잘못된 링크를 클릭했습니다.",
|
||||
"yes": "예",
|
||||
"unsaved_changes": "일부 변경 사항이 저장되지 않았습니다. 무시할까요?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "결과를 찾을 수 없음",
|
||||
"no_more_results": "페이지 번호 %{page}이(가) 경계를 벗어났습니다. 이전 페이지를 시도해 보세요.",
|
||||
"page_out_of_boundaries": "페이지 번호 %{page}이(가) 경계를 벗어남",
|
||||
"page_out_from_end": "마지막 페이지 뒤로 갈 수 없음",
|
||||
"page_out_from_begin": "첫 페이지 앞으로 갈 수 없음",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
|
||||
"page_rows_per_page": "페이지당 항목:",
|
||||
"next": "다음",
|
||||
"prev": "이전",
|
||||
"skip_nav": "콘텐츠 건너뛰기"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "요소 업데이트됨 |||| %{smart_count} 개 요소 업데이트됨",
|
||||
"created": "요소 생성됨",
|
||||
"deleted": "요소 삭제됨 |||| %{smart_count} 개 요소 삭제됨",
|
||||
"bad_item": "잘못된 요소",
|
||||
"item_doesnt_exist": "요소가 존재하지 않음",
|
||||
"http_error": "서버 통신 오류",
|
||||
"data_provider_error": "dataProvider 오류입니다. 자세한 내용은 콘솔을 확인하세요.",
|
||||
"i18n_error": "지정된 언어에 대한 번역을 로드할 수 없음",
|
||||
"canceled": "작업이 취소됨",
|
||||
"logged_out": "세션이 종료되었습니다. 다시 연결하세요.",
|
||||
"new_version": "새로운 버전이 출시되었습니다! 이 창을 새로 고침하세요."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "표시할 열",
|
||||
"layout": "레이아웃",
|
||||
"grid": "격자",
|
||||
"table": "표"
|
||||
}
|
||||
"album": {
|
||||
"name": "앨범 |||| 앨범들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"artist": "아티스트",
|
||||
"duration": "시간",
|
||||
"songCount": "노래",
|
||||
"playCount": "재생 횟수",
|
||||
"size": "크기",
|
||||
"name": "이름",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"date": "녹음 날짜",
|
||||
"originalDate": "오리지널",
|
||||
"releaseDate": "발매일",
|
||||
"releases": "발매 음반 |||| 발매 음반들",
|
||||
"released": "발매됨",
|
||||
"updatedAt": "업데이트됨",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"createdAt": "추가된 날짜",
|
||||
"recordLabel": "레이블",
|
||||
"catalogNum": "카탈로그 번호",
|
||||
"releaseType": "유형",
|
||||
"grouping": "그룹",
|
||||
"media": "미디어",
|
||||
"mood": "분위기"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "재생",
|
||||
"playNext": "다음 재생",
|
||||
"addToQueue": "나중에 재생",
|
||||
"share": "공유",
|
||||
"shuffle": "셔플",
|
||||
"addToPlaylist": "재생목록 추가",
|
||||
"download": "다운로드",
|
||||
"info": "정보 얻기"
|
||||
},
|
||||
"lists": {
|
||||
"all": "모두",
|
||||
"random": "랜덤",
|
||||
"recentlyAdded": "최근 추가됨",
|
||||
"recentlyPlayed": "최근 재생됨",
|
||||
"mostPlayed": "가장 많이 재생됨",
|
||||
"starred": "즐겨찾기",
|
||||
"topRated": "높은 평가"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "참고",
|
||||
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
|
||||
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
|
||||
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
|
||||
"noPlaylistsAvailable": "사용 가능한 노래 없음",
|
||||
"delete_user_title": "사용자 '%{name}' 삭제",
|
||||
"delete_user_content": "이 사용자와 (재생목록 및 기본 설정 포함된) 모든 데이터를 삭제할까요?",
|
||||
"notifications_blocked": "탐색기 설정에서 이 사이트의 알림을 차단하였음",
|
||||
"notifications_not_available": "이 탐색기는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하지 않음",
|
||||
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음",
|
||||
"lastfmLinkFailure": "Last.fm을 연결할 수 없음",
|
||||
"lastfmUnlinkSuccess": "Last.fm이 연결 해제되었고 스크로블링이 비활성화되었음",
|
||||
"lastfmUnlinkFailure": "Last.fm을 연결 해제할 수 없음",
|
||||
"openIn": {
|
||||
"lastfm": "Last.fm에서 열기",
|
||||
"musicbrainz": "MusicBrainz에서 열기"
|
||||
},
|
||||
"lastfmLink": "더 읽기...",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz가 성공적으로 연결되었고 스크로블링이 사용자로 활성화되었음: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz를 연결할 수 없음: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz가 연결 해제되었고 스크로블링이 비활성화되었음",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz를 연결 해제할 수 없음",
|
||||
"downloadOriginalFormat": "오리지널 형식으로 다운로드",
|
||||
"shareOriginalFormat": "오리지널 형식으로 공유",
|
||||
"shareDialogTitle": "%{resource} '%{name}' 공유",
|
||||
"shareBatchDialogTitle": "1 %{resource} 공유 |||| %{smart_count} %{resource} 공유",
|
||||
"shareSuccess": "URL이 클립보드에 복사됨: %{url}",
|
||||
"shareFailure": "%{url}을 클립보드에 복사하는 중 오류 발생",
|
||||
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
|
||||
"shareCopyToClipboard": "클립보드에 복사: Ctrl+C, Enter"
|
||||
"artist": {
|
||||
"name": "아티스트 |||| 아티스트들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"albumCount": "앨범 수",
|
||||
"songCount": "노래 수",
|
||||
"size": "크기",
|
||||
"playCount": "재생 횟수",
|
||||
"rating": "평가",
|
||||
"genre": "장르",
|
||||
"role": "역할"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "앨범 아티스트 |||| 앨범 아티스트들",
|
||||
"artist": "아티스트 |||| 아티스트들",
|
||||
"composer": "작곡가 |||| 작곡가들",
|
||||
"conductor": "지휘자 |||| 지휘자들",
|
||||
"lyricist": "작사가 |||| 작사가들",
|
||||
"arranger": "편곡가 |||| 편곡가들",
|
||||
"producer": "제작자 |||| 제작자들",
|
||||
"director": "감독 |||| 감독들",
|
||||
"engineer": "엔지니어 |||| 엔지니어들",
|
||||
"mixer": "믹서 |||| 믹서들",
|
||||
"remixer": "리믹서 |||| 리믹서들",
|
||||
"djmixer": "DJ 믹서 |||| DJ 믹서들",
|
||||
"performer": "공연자 |||| 공연자들"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
"settings": "설정",
|
||||
"version": "버전",
|
||||
"theme": "테마",
|
||||
"personal": {
|
||||
"name": "개인 설정",
|
||||
"options": {
|
||||
"theme": "테마",
|
||||
"language": "언어",
|
||||
"defaultView": "기본 보기",
|
||||
"desktop_notifications": "데스크톱 알림",
|
||||
"lastfmScrobbling": "Last.fm으로 스크로블",
|
||||
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
|
||||
"replaygain": "리플레이게인 모드",
|
||||
"preAmp": "리플레이게인 프리앰프 (dB)",
|
||||
"gain": {
|
||||
"none": "비활성화",
|
||||
"album": "앨범 게인 사용",
|
||||
"track": "트랙 게인 사용"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "앨범",
|
||||
"about": "정보",
|
||||
"playlists": "재생목록",
|
||||
"sharedPlaylists": "공유된 재생목록"
|
||||
"user": {
|
||||
"name": "사용자 |||| 사용자들",
|
||||
"fields": {
|
||||
"userName": "사용자이름",
|
||||
"isAdmin": "관리자",
|
||||
"lastLoginAt": "마지막 로그인",
|
||||
"lastAccessAt": "마지막 접속",
|
||||
"updatedAt": "업데이트됨",
|
||||
"name": "이름",
|
||||
"password": "비밀번호",
|
||||
"createdAt": "생성됨",
|
||||
"changePassword": "비밀번호를 변경할까요?",
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"newPassword": "새 비밀번호",
|
||||
"token": "토큰"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "이름 변경 사항은 다음 로그인 시에만 반영됨"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "사용자 생성됨",
|
||||
"updated": "사용자 업데이트됨",
|
||||
"deleted": "사용자 삭제됨"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "대기열 재생",
|
||||
"openText": "열기",
|
||||
"closeText": "닫기",
|
||||
"notContentText": "음악 없음",
|
||||
"clickToPlayText": "재생하려면 클릭",
|
||||
"clickToPauseText": "일시 중지하려면 클릭",
|
||||
"nextTrackText": "다음 트랙",
|
||||
"previousTrackText": "이전 트랙",
|
||||
"reloadText": "다시 로드하기",
|
||||
"volumeText": "볼륨",
|
||||
"toggleLyricText": "가사 전환",
|
||||
"toggleMiniModeText": "최소화",
|
||||
"destroyText": "제거",
|
||||
"downloadText": "다운로드",
|
||||
"removeAudioListsText": "오디오 목록 삭제",
|
||||
"clickToDeleteText": "%{name}을(를) 삭제하려면 클릭",
|
||||
"emptyLyricText": "가사 없음",
|
||||
"playModeText": {
|
||||
"order": "순서대로",
|
||||
"orderLoop": "반복",
|
||||
"singleLoop": "노래 하나 반복",
|
||||
"shufflePlay": "셔플"
|
||||
}
|
||||
"name": "플레이어 |||| 플레이어들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"transcodingId": "트랜스코딩",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"client": "클라이언트",
|
||||
"userName": "사용자이름",
|
||||
"lastSeen": "마지막으로 봤음",
|
||||
"reportRealPath": "실제 경로 보고서",
|
||||
"scrobbleEnabled": "외부 서비스에 스크로블 보내기"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "홈페이지",
|
||||
"source": "소스 코드",
|
||||
"featureRequests": "기능 요청"
|
||||
}
|
||||
"transcoding": {
|
||||
"name": "트랜스코딩 |||| 트랜스코딩",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"targetFormat": "대상 형식",
|
||||
"defaultBitRate": "기본 비트레이트",
|
||||
"command": "명령"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "활동",
|
||||
"totalScanned": "스캔된 전체 폴더",
|
||||
"quickScan": "빠른 스캔",
|
||||
"fullScan": "전체 스캔",
|
||||
"serverUptime": "서버 가동 시간",
|
||||
"serverDown": "오프라인"
|
||||
"playlist": {
|
||||
"name": "재생목록 |||| 재생목록들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"duration": "지속",
|
||||
"ownerName": "소유자",
|
||||
"public": "공개",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"songCount": "노래",
|
||||
"comment": "댓글",
|
||||
"sync": "자동 가져오기",
|
||||
"path": "다음에서 가져오기"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "재생목록 선택:",
|
||||
"addNewPlaylist": "\"%{name}\" 만들기",
|
||||
"export": "내보내기",
|
||||
"makePublic": "공개 만들기",
|
||||
"makePrivate": "비공개 만들기"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "중복된 노래 추가",
|
||||
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 단축키",
|
||||
"hotkeys": {
|
||||
"show_help": "이 도움말 표시",
|
||||
"toggle_menu": "메뉴 사이드바 전환",
|
||||
"toggle_play": "재생 / 일시 중지",
|
||||
"prev_song": "이전 노래",
|
||||
"next_song": "다음 노래",
|
||||
"vol_up": "볼륨 높이기",
|
||||
"vol_down": "볼륨 낮추기",
|
||||
"toggle_love": "이 트랙을 즐겨찾기에 추가",
|
||||
"current_song": "현재 노래로 이동"
|
||||
}
|
||||
"radio": {
|
||||
"name": "라디오 |||| 라디오들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"streamUrl": "스트리밍 URL",
|
||||
"homePageUrl": "홈페이지 URL",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "지금 재생"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "공유 |||| 공유되는 것들",
|
||||
"fields": {
|
||||
"username": "공유됨",
|
||||
"url": "URL",
|
||||
"description": "설명",
|
||||
"downloadable": "다운로드를 허용할까요?",
|
||||
"contents": "컨텐츠",
|
||||
"expiresAt": "만료",
|
||||
"lastVisitedAt": "마지막 방문",
|
||||
"visitCount": "방문 수",
|
||||
"format": "형식",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "누락 파일 |||| 누락 파일들",
|
||||
"empty": "누락 파일 없음",
|
||||
"fields": {
|
||||
"path": "경로",
|
||||
"size": "크기",
|
||||
"updatedAt": "사라짐"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "제거"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "누락된 파일이 제거되었음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Navidrome을 설치해 주셔서 감사합니다!",
|
||||
"welcome2": "관리자를 만들고 시작해 보세요",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"buttonCreateAdmin": "관리자 만들기",
|
||||
"auth_check_error": "계속하려면 로그인하세요",
|
||||
"user_menu": "프로파일",
|
||||
"username": "사용자이름",
|
||||
"password": "비밀번호",
|
||||
"sign_in": "가입",
|
||||
"sign_in_error": "인증에 실패했습니다. 다시 시도하세요",
|
||||
"logout": "로그아웃",
|
||||
"insightsCollectionNote": "Navidrome은 프로젝트 개선을 위해 익명의 사용 데이터를\n수집합니다. 자세한 내용을 알아보고 원치 않으시면 [여기]를\n클릭하여 수신 거부하세요"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "문자와 숫자만 사용하세요",
|
||||
"passwordDoesNotMatch": "비밀번호가 일치하지 않음",
|
||||
"required": "필수 항목임",
|
||||
"minLength": "%{min}자 이하여야 함",
|
||||
"maxLength": "%{max}자 이하여야 함",
|
||||
"minValue": "%{min}자 이상이어야 함",
|
||||
"maxValue": "%{max}자 이하여야 함",
|
||||
"number": "숫자여야 함",
|
||||
"email": "유효한 이메일이어야 함",
|
||||
"oneOf": "다음 중 하나여야 함: %{options}",
|
||||
"regex": "특정 형식(정규식)과 일치해야 함: %{pattern}",
|
||||
"unique": "고유해야 함",
|
||||
"url": "유효한 URL이어야 함"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "필터 추가",
|
||||
"add": "추가",
|
||||
"back": "뒤로 가기",
|
||||
"bulk_actions": "1 item selected |||| %{smart_count} 개 항목이 선택되었음",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "취소",
|
||||
"clear_input_value": "값 지우기",
|
||||
"clone": "복제",
|
||||
"confirm": "확인",
|
||||
"create": "만들기",
|
||||
"delete": "삭제",
|
||||
"edit": "편집",
|
||||
"export": "내보내기",
|
||||
"list": "목록",
|
||||
"refresh": "새로 고침",
|
||||
"remove_filter": "이 필터 제거",
|
||||
"remove": "제거",
|
||||
"save": "저장",
|
||||
"search": "검색",
|
||||
"show": "표시",
|
||||
"sort": "정렬",
|
||||
"undo": "실행 취소",
|
||||
"expand": "확장",
|
||||
"close": "닫기",
|
||||
"open_menu": "메뉴 열기",
|
||||
"close_menu": "메뉴 닫기",
|
||||
"unselect": "선택 해제",
|
||||
"skip": "건너뛰기",
|
||||
"share": "공유",
|
||||
"download": "다운로드"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "예",
|
||||
"false": "아니오"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} 만들기",
|
||||
"dashboard": "대시보드",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "문제가 발생하였음",
|
||||
"list": "%{name}",
|
||||
"loading": "불러오기 중",
|
||||
"not_found": "찾을 수 없음",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "아직 %{name}이(가) 없습니다.",
|
||||
"invite": "추가할까요?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "업로드할 파일을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 파일을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "업로드할 사진을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 사진을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "참조 데이터를 찾을 수 없습니다.",
|
||||
"many_missing": "연관된 참조 중 적어도 하나는 더 이상 사용할 수 없는 것 같습니다.",
|
||||
"single_missing": "연관된 참조는 더 이상 사용할 수 없는 것 같습니다."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "비밀번호 숨기기",
|
||||
"toggle_hidden": "비밀번호 표시"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "정보",
|
||||
"are_you_sure": "확실한가요?",
|
||||
"bulk_delete_content": "이 %{name}을(를) 삭제할까요? |||| 이 %{smart_count} 개의 항목을 삭제할까요?",
|
||||
"bulk_delete_title": "%{name} 삭제 |||| %{smart_count} %{name} 삭제",
|
||||
"delete_content": "이 항목을 삭제할까요?",
|
||||
"delete_title": "%{name} #%{id} 삭제",
|
||||
"details": "상세정보",
|
||||
"error": "클라이언트 오류가 발생하여 요청을 완료할 수 없습니다.",
|
||||
"invalid_form": "양식이 유효하지 않습니다. 오류를 확인하세요",
|
||||
"loading": "페이지가 로드 중입니다. 잠시만 기다려 주세요",
|
||||
"no": "아니오",
|
||||
"not_found": "잘못된 URL을 입력했거나 잘못된 링크를 클릭했습니다.",
|
||||
"yes": "예",
|
||||
"unsaved_changes": "일부 변경 사항이 저장되지 않았습니다. 무시할까요?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "결과를 찾을 수 없음",
|
||||
"no_more_results": "페이지 번호 %{page}이(가) 경계를 벗어났습니다. 이전 페이지를 시도해 보세요.",
|
||||
"page_out_of_boundaries": "페이지 번호 %{page}이(가) 경계를 벗어남",
|
||||
"page_out_from_end": "마지막 페이지 뒤로 갈 수 없음",
|
||||
"page_out_from_begin": "1 페이지 앞으로 갈 수 없음",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
|
||||
"page_rows_per_page": "페이지당 항목:",
|
||||
"next": "다음",
|
||||
"prev": "이전",
|
||||
"skip_nav": "콘텐츠 건너뛰기"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "요소 업데이트됨 |||| %{smart_count} 개 요소 업데이트됨",
|
||||
"created": "요소 생성됨",
|
||||
"deleted": "요소 삭제됨 |||| %{smart_count} 개 요소 삭제됨",
|
||||
"bad_item": "잘못된 요소",
|
||||
"item_doesnt_exist": "요소가 존재하지 않음",
|
||||
"http_error": "서버 통신 오류",
|
||||
"data_provider_error": "dataProvider 오류입니다. 자세한 내용은 콘솔을 확인하세요.",
|
||||
"i18n_error": "지정된 언어에 대한 번역을 로드할 수 없음",
|
||||
"canceled": "작업이 취소됨",
|
||||
"logged_out": "세션이 종료되었습니다. 다시 연결하세요.",
|
||||
"new_version": "새로운 버전이 출시되었습니다! 이 창을 새로 고침하세요."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "표시할 열",
|
||||
"layout": "레이아웃",
|
||||
"grid": "격자",
|
||||
"table": "표"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "참고",
|
||||
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
|
||||
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
|
||||
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
|
||||
"noPlaylistsAvailable": "사용 가능한 노래 없음",
|
||||
"delete_user_title": "사용자 '%{name}' 삭제",
|
||||
"delete_user_content": "이 사용자와 해당 사용자의 모든 데이터(재생 목록 및 환경 설정 포함)를 삭제할까요?",
|
||||
"remove_missing_title": "누락된 파일들 제거",
|
||||
"remove_missing_content": "선택한 누락된 파일을 데이터베이스에서 삭제할까요? 삭제하면 재생 횟수 및 평점을 포함하여 해당 파일에 대한 모든 참조가 영구적으로 삭제됩니다.",
|
||||
"notifications_blocked": "브라우저 설정에서 이 사이트의 알림을 차단하였음",
|
||||
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하고 있지 않음",
|
||||
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음",
|
||||
"lastfmLinkFailure": "Last.fm을 연결 해제할 수 없음",
|
||||
"lastfmUnlinkSuccess": "Last.fm 연결 해제 및 스크로블링 비활성화",
|
||||
"lastfmUnlinkFailure": "Last.fm을 연결 해제할 수 없음",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz가 성공적으로 연결되었고, 다음 사용자로 스크로블링이 활성화되었음: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz를 연결할 수 없음: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz가 연결 해제되었고 스크로블링이 비활성화되었음",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz를 연결 해제할 수 없음",
|
||||
"openIn": {
|
||||
"lastfm": "Last.fm에서 열기",
|
||||
"musicbrainz": "MusicBrainz에서 열기"
|
||||
},
|
||||
"lastfmLink": "더 읽기...",
|
||||
"shareOriginalFormat": "오리지널 형식으로 공유",
|
||||
"shareDialogTitle": "%{resource} '%{name}' 공유",
|
||||
"shareBatchDialogTitle": "1 %{resource} 공유 |||| %{smart_count} %{resource} 공유",
|
||||
"shareCopyToClipboard": "클립보드에 복사: Ctrl+C, Enter",
|
||||
"shareSuccess": "URL이 클립보드에 복사되었음: %{url}",
|
||||
"shareFailure": "URL %{url}을 클립보드에 복사하는 중 오류가 발생하였음",
|
||||
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
|
||||
"downloadOriginalFormat": "오리지널 형식으로 다운로드"
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
"settings": "설정",
|
||||
"version": "버전",
|
||||
"theme": "테마",
|
||||
"personal": {
|
||||
"name": "개인 설정",
|
||||
"options": {
|
||||
"theme": "테마",
|
||||
"language": "언어",
|
||||
"defaultView": "기본 보기",
|
||||
"desktop_notifications": "데스크톱 알림",
|
||||
"lastfmNotConfigured": "Last.fm API 키가 구성되지 않았음",
|
||||
"lastfmScrobbling": "Last.fm으로 스크로블",
|
||||
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
|
||||
"replaygain": "리플레이게인 모드",
|
||||
"preAmp": "리플레이게인 프리앰프 (dB)",
|
||||
"gain": {
|
||||
"none": "비활성화",
|
||||
"album": "앨범 게인 사용",
|
||||
"track": "트랙 게인 사용"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "앨범",
|
||||
"playlists": "재생목록",
|
||||
"sharedPlaylists": "공유된 재생목록",
|
||||
"about": "정보"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "대기열 재생",
|
||||
"openText": "열기",
|
||||
"closeText": "닫기",
|
||||
"notContentText": "음악 없음",
|
||||
"clickToPlayText": "재생하려면 클릭",
|
||||
"clickToPauseText": "일시 중지하려면 클릭",
|
||||
"nextTrackText": "다음 트랙",
|
||||
"previousTrackText": "이전 트랙",
|
||||
"reloadText": "다시 로드하기",
|
||||
"volumeText": "볼륨",
|
||||
"toggleLyricText": "가사 전환",
|
||||
"toggleMiniModeText": "최소화",
|
||||
"destroyText": "제거",
|
||||
"downloadText": "다운로드",
|
||||
"removeAudioListsText": "오디오 목록 삭제",
|
||||
"clickToDeleteText": "%{name}을(를) 삭제하려면 클릭",
|
||||
"emptyLyricText": "가사 없음",
|
||||
"playModeText": {
|
||||
"order": "순서대로",
|
||||
"orderLoop": "반복",
|
||||
"singleLoop": "노래 하나 반복",
|
||||
"shufflePlay": "셔플"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "홈페이지",
|
||||
"source": "소스 코드",
|
||||
"featureRequests": "기능 요청",
|
||||
"lastInsightsCollection": "마지막 인사이트 컬렉션",
|
||||
"insights": {
|
||||
"disabled": "비활성화",
|
||||
"waiting": "대기중"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "활동",
|
||||
"totalScanned": "스캔된 전체 폴더",
|
||||
"quickScan": "빠른 스캔",
|
||||
"fullScan": "전체 스캔",
|
||||
"serverUptime": "서버 가동 시간",
|
||||
"serverDown": "오프라인"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 단축키",
|
||||
"hotkeys": {
|
||||
"show_help": "이 도움말 표시",
|
||||
"toggle_menu": "메뉴 사이드바 전환",
|
||||
"toggle_play": "재생 / 일시 중지",
|
||||
"prev_song": "이전 노래",
|
||||
"next_song": "다음 노래",
|
||||
"current_song": "현재 노래로 이동",
|
||||
"vol_up": "볼륨 높이기",
|
||||
"vol_down": "볼륨 낮추기",
|
||||
"toggle_love": "이 트랙을 즐겨찾기에 추가"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"languageName": "Engelsk",
|
||||
"languageName": "Norsk",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Låt |||| Låter",
|
||||
"name": "Sang |||| Sanger",
|
||||
"fields": {
|
||||
"albumArtist": "Album Artist",
|
||||
"duration": "Tid",
|
||||
@@ -11,164 +11,165 @@
|
||||
"title": "Tittel",
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"path": "Filbane",
|
||||
"path": "Filsti",
|
||||
"genre": "Sjanger",
|
||||
"compilation": "Samling",
|
||||
"compilation": "Samlingg",
|
||||
"year": "År",
|
||||
"size": "Filstørrelse",
|
||||
"updatedAt": "Oppdatert kl",
|
||||
"bitRate": "Bithastighet",
|
||||
"discSubtitle": "Diskundertekst",
|
||||
"updatedAt": "Oppdatert",
|
||||
"bitRate": "Bit rate",
|
||||
"bitDepth": "Bit depth",
|
||||
"channels": "Kanaler",
|
||||
"discSubtitle": "Disk Undertittel",
|
||||
"starred": "Favoritt",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Vurdering",
|
||||
"rating": "Rangering",
|
||||
"quality": "Kvalitet",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Sist spilt",
|
||||
"channels": "Kanaler",
|
||||
"createdAt": "",
|
||||
"grouping": "",
|
||||
"mood": "",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": "",
|
||||
"bitDepth": ""
|
||||
"playDate": "Sist Avspilt",
|
||||
"createdAt": "Lagt til",
|
||||
"grouping": "Gruppering",
|
||||
"mood": "Stemning",
|
||||
"participants": "Ytterlige deltakere",
|
||||
"tags": "Ytterlige Tags",
|
||||
"mappedTags": "Kartlagte tags",
|
||||
"rawTags": "Rå tags"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Spill Senere",
|
||||
"playNow": "Leke nå",
|
||||
"addToQueue": "Avspill senere",
|
||||
"playNow": "Avspill nå",
|
||||
"addToPlaylist": "Legg til i spilleliste",
|
||||
"shuffleAll": "Bland alle",
|
||||
"download": "nedlasting",
|
||||
"playNext": "Spill Neste",
|
||||
"info": "Få informasjon"
|
||||
"shuffleAll": "Shuffle Alle",
|
||||
"download": "Last ned",
|
||||
"playNext": "Avspill neste",
|
||||
"info": "Få Info"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Album",
|
||||
"name": "Album |||| Album",
|
||||
"fields": {
|
||||
"albumArtist": "Album Artist",
|
||||
"artist": "Artist",
|
||||
"duration": "Tid",
|
||||
"songCount": "Sanger",
|
||||
"playCount": "Avspillinger",
|
||||
"size": "Størrelse",
|
||||
"name": "Navn",
|
||||
"genre": "Sjanger",
|
||||
"compilation": "Samling",
|
||||
"year": "År",
|
||||
"updatedAt": "Oppdatert kl",
|
||||
"date": "Inspillingsdato",
|
||||
"originalDate": "Original",
|
||||
"releaseDate": "Utgitt",
|
||||
"releases": "Utgivelse |||| Utgivelser",
|
||||
"released": "Utgitt",
|
||||
"updatedAt": "Oppdatert",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Vurdering",
|
||||
"createdAt": "",
|
||||
"size": "",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": "",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": ""
|
||||
"rating": "Rangering",
|
||||
"createdAt": "Lagt Til",
|
||||
"recordLabel": "Plateselskap",
|
||||
"catalogNum": "Katalognummer",
|
||||
"releaseType": "Type",
|
||||
"grouping": "Gruppering",
|
||||
"media": "Media",
|
||||
"mood": "Stemning"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Spill",
|
||||
"playNext": "Spill neste",
|
||||
"addToQueue": "Spille senere",
|
||||
"shuffle": "Bland",
|
||||
"playAll": "Avspill",
|
||||
"playNext": "Avspill Neste",
|
||||
"addToQueue": "Avspill Senere",
|
||||
"share": "Del",
|
||||
"shuffle": "Shuffle",
|
||||
"addToPlaylist": "Legg til i spilleliste",
|
||||
"download": "nedlasting",
|
||||
"info": "Få informasjon",
|
||||
"share": ""
|
||||
"download": "Last ned",
|
||||
"info": "Få Info"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alle",
|
||||
"random": "Tilfeldig",
|
||||
"recentlyAdded": "Nylig lagt til",
|
||||
"recentlyPlayed": "Nylig spilt",
|
||||
"mostPlayed": "Mest spilte",
|
||||
"recentlyPlayed": "Nylig Avspilt",
|
||||
"mostPlayed": "Mest Avspilt",
|
||||
"starred": "Favoritter",
|
||||
"topRated": "Topp rangert"
|
||||
"topRated": "Top Rangert"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artist |||| Artister",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"albumCount": "Antall album",
|
||||
"songCount": "Antall sanger",
|
||||
"playCount": "Spiller",
|
||||
"rating": "Vurdering",
|
||||
"albumCount": "Album Antall",
|
||||
"songCount": "Song Antall",
|
||||
"size": "Størrelse",
|
||||
"playCount": "Avspillinger",
|
||||
"rating": "Rangering",
|
||||
"genre": "Sjanger",
|
||||
"size": "",
|
||||
"role": ""
|
||||
"role": "Rolle"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
"albumartist": "Album Artist |||| Album Artister",
|
||||
"artist": "Artist |||| Artister",
|
||||
"composer": "Composer |||| Composers",
|
||||
"conductor": "Conductor |||| Conductors",
|
||||
"lyricist": "Lyriker |||| Lyriker",
|
||||
"arranger": "Arranger |||| Arrangers",
|
||||
"producer": "Produsent |||| Produsenter",
|
||||
"director": "Director |||| Directors",
|
||||
"engineer": "Engineer |||| Engineers",
|
||||
"mixer": "Mixer |||| Mixers",
|
||||
"remixer": "Remixer |||| Remixers",
|
||||
"djmixer": "DJ Mixer |||| DJ Mixers",
|
||||
"performer": "Performer |||| Performers"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Bruker |||| Brukere",
|
||||
"fields": {
|
||||
"userName": "Brukernavn",
|
||||
"isAdmin": "er admin",
|
||||
"lastLoginAt": "Siste pålogging kl",
|
||||
"updatedAt": "Oppdatert kl",
|
||||
"isAdmin": "Admin",
|
||||
"lastLoginAt": "Sist Pålogging",
|
||||
"lastAccessAt": "Sist Tilgang",
|
||||
"updatedAt": "Oppdatert",
|
||||
"name": "Navn",
|
||||
"password": "Passord",
|
||||
"createdAt": "Opprettet kl",
|
||||
"changePassword": "Bytte Passord",
|
||||
"createdAt": "Opprettet",
|
||||
"changePassword": "Bytt Passord?",
|
||||
"currentPassword": "Nåværende Passord",
|
||||
"newPassword": "Nytt Passord",
|
||||
"token": "Token",
|
||||
"lastAccessAt": ""
|
||||
"token": "Token"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Endringer i navnet ditt vil kun gjenspeiles ved neste pålogging"
|
||||
"name": "Navnendringer vil ikke være synlig før neste pålogging"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Bruker opprettet",
|
||||
"updated": "Bruker oppdatert",
|
||||
"deleted": "Bruker fjernet"
|
||||
"deleted": "Bruker slettet"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Skriv inn ListenBrainz-brukertokenet ditt.",
|
||||
"clickHereForToken": "Klikk her for å få tokenet ditt"
|
||||
"listenBrainzToken": "Fyll inn din ListenBrainz bruker token.",
|
||||
"clickHereForToken": "Klikk her for å hente din token"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Avspiller |||| Avspillere",
|
||||
"name": "Musikkavspiller |||| Musikkavspillere",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"transcodingId": "Omkoding",
|
||||
"maxBitRate": "Maks. Bithastighet",
|
||||
"transcodingId": "Transkoding",
|
||||
"maxBitRate": "Maks. Bit Rate",
|
||||
"client": "Klient",
|
||||
"userName": "Brukernavn",
|
||||
"lastSeen": "Sist sett kl",
|
||||
"reportRealPath": "Rapporter ekte sti",
|
||||
"lastSeen": "Sist sett",
|
||||
"reportRealPath": "Rapporter ekte filsti",
|
||||
"scrobbleEnabled": "Send Scrobbles til eksterne tjenester"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Omkoding |||| Omkodinger",
|
||||
"name": "Transkoding |||| Transkodinger",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"targetFormat": "Målformat",
|
||||
"defaultBitRate": "Standard bithastighet",
|
||||
"targetFormat": "Mål Format",
|
||||
"defaultBitRate": "Default Bit Rate",
|
||||
"command": "Kommando"
|
||||
}
|
||||
},
|
||||
@@ -176,135 +177,137 @@
|
||||
"name": "Spilleliste |||| Spillelister",
|
||||
"fields": {
|
||||
"name": "Navn",
|
||||
"duration": "Varighet",
|
||||
"ownerName": "Eieren",
|
||||
"duration": "Lengde",
|
||||
"ownerName": "Eier",
|
||||
"public": "Offentlig",
|
||||
"updatedAt": "Oppdatert kl",
|
||||
"createdAt": "Opprettet kl",
|
||||
"updatedAt": "Oppdatert",
|
||||
"createdAt": "Opprettet",
|
||||
"songCount": "Sanger",
|
||||
"comment": "Kommentar",
|
||||
"sync": "Autoimport",
|
||||
"path": "Import fra"
|
||||
"sync": "Auto-importer",
|
||||
"path": "Importer fra"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Velg en spilleliste:",
|
||||
"addNewPlaylist": "Opprett \"%{name}\"",
|
||||
"export": "Eksport",
|
||||
"makePublic": "Gjør offentlig",
|
||||
"makePrivate": "Gjør privat"
|
||||
"export": "Eksporter",
|
||||
"makePublic": "Gjør Offentlig",
|
||||
"makePrivate": "Gjør Privat"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Legg til dupliserte sanger",
|
||||
"song_exist": "Det legges til duplikater i spillelisten. Vil du legge til duplikatene eller hoppe over dem?"
|
||||
"duplicate_song": "Legg til Duplikater",
|
||||
"song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "",
|
||||
"name": "Radio |||| Radio",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"streamUrl": "",
|
||||
"homePageUrl": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": ""
|
||||
"name": "Navn",
|
||||
"streamUrl": "Stream URL",
|
||||
"homePageUrl": "Hjemmeside URL",
|
||||
"updatedAt": "Oppdatert",
|
||||
"createdAt": "Opprettet"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": ""
|
||||
"playNow": "Avspill"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "",
|
||||
"name": "Del |||| Delinger",
|
||||
"fields": {
|
||||
"username": "",
|
||||
"url": "",
|
||||
"description": "",
|
||||
"contents": "",
|
||||
"expiresAt": "",
|
||||
"lastVisitedAt": "",
|
||||
"visitCount": "",
|
||||
"format": "",
|
||||
"maxBitRate": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"downloadable": ""
|
||||
}
|
||||
"username": "Delt Av",
|
||||
"url": "URL",
|
||||
"description": "Beskrivelse",
|
||||
"downloadable": "Tillat Nedlastinger?",
|
||||
"contents": "Innhold",
|
||||
"expiresAt": "Utløper",
|
||||
"lastVisitedAt": "Sist Besøkt",
|
||||
"visitCount": "Visninger",
|
||||
"format": "Format",
|
||||
"maxBitRate": "Maks. Bit Rate",
|
||||
"updatedAt": "Oppdatert",
|
||||
"createdAt": "Opprettet"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "",
|
||||
"name": "Manglende Fil|||| Manglende Filer",
|
||||
"empty": "Ingen Manglende Filer",
|
||||
"fields": {
|
||||
"path": "",
|
||||
"size": "",
|
||||
"updatedAt": ""
|
||||
"path": "Filsti",
|
||||
"size": "Størrelse",
|
||||
"updatedAt": "Ble borte"
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
"remove": "Fjern"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
},
|
||||
"empty": ""
|
||||
"removed": "Manglende fil(er) fjernet"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Takk for at du installerte Navidrome!",
|
||||
"welcome2": "Opprett en admin -bruker for å starte",
|
||||
"welcome2": "La oss begynne med å lage en admin bruker.",
|
||||
"confirmPassword": "Bekreft Passord",
|
||||
"buttonCreateAdmin": "Opprett Admin",
|
||||
"auth_check_error": "Vennligst Logg inn for å fortsette",
|
||||
"auth_check_error": "Logg inn for å fortsette",
|
||||
"user_menu": "Profil",
|
||||
"username": "Brukernavn",
|
||||
"password": "Passord",
|
||||
"sign_in": "Logg inn",
|
||||
"sign_in_error": "Autentisering mislyktes. Prøv på nytt",
|
||||
"sign_in_error": "Autentiseringsfeil, vennligst prøv igjen",
|
||||
"logout": "Logg ut",
|
||||
"insightsCollectionNote": ""
|
||||
"insightsCollectionNote": "Navidrome innhenter anonymisert forbruksdata\nfor å hjelpe og forbedre prosjektet.\nTrykk [her] for å lære mer og for å melde deg av hvis ønskelig."
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "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",
|
||||
"invalidChars": "Det er kun bokstaver og tall som støttes",
|
||||
"passwordDoesNotMatch": "Passord samstemmer ikke",
|
||||
"required": "Kreves",
|
||||
"minLength": "Må være minst %{min} karakterer.",
|
||||
"maxLength": "Må være %{max} karakterer eller mindre",
|
||||
"minValue": "Må være minst %{min}",
|
||||
"maxValue": "Må være %{max} eller mindre",
|
||||
"number": "Må være et tall",
|
||||
"email": "Må være en gyldig e-post",
|
||||
"email": "Må være en gyldig epost",
|
||||
"oneOf": "Må være en av: %{options}",
|
||||
"regex": "Må samsvare med et spesifikt format (regexp): %{pattern}",
|
||||
"unique": "Må være unik",
|
||||
"url": ""
|
||||
"regex": "Må samstemme med et spesifikt format (regexp): %{pattern}",
|
||||
"unique": "Må være unikt",
|
||||
"url": "Må være en gyldig URL"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Legg til filter",
|
||||
"add": "Legge til",
|
||||
"back": "Gå tilbake",
|
||||
"bulk_actions": "1 element valgt |||| %{smart_count} elementer er valgt",
|
||||
"add": "Legg Til",
|
||||
"back": "Tilbake",
|
||||
"bulk_actions": "1 element valgt |||| %{smart_count} elementer valgt",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Avbryt",
|
||||
"clear_input_value": "Klar verdi",
|
||||
"clear_input_value": "Nullstill verdi",
|
||||
"clone": "Klone",
|
||||
"confirm": "Bekrefte",
|
||||
"create": "Skape",
|
||||
"confirm": "Bekreft",
|
||||
"create": "Opprett",
|
||||
"delete": "Slett",
|
||||
"edit": "Redigere",
|
||||
"export": "Eksport",
|
||||
"edit": "Rediger",
|
||||
"export": "Eksporter",
|
||||
"list": "Liste",
|
||||
"refresh": "oppdater",
|
||||
"refresh": "Oppdater",
|
||||
"remove_filter": "Fjern dette filteret",
|
||||
"remove": "Fjerne",
|
||||
"remove": "Fjern",
|
||||
"save": "Lagre",
|
||||
"search": "Søk",
|
||||
"show": "Vis",
|
||||
"sort": "Sortere",
|
||||
"sort": "Sorter",
|
||||
"undo": "Angre",
|
||||
"expand": "Utvide",
|
||||
"expand": "Utvid",
|
||||
"close": "Lukk",
|
||||
"open_menu": "Åpne menyen",
|
||||
"close_menu": "Lukk menyen",
|
||||
"unselect": "Fjern valget",
|
||||
"open_menu": "Åpne meny",
|
||||
"close_menu": "Lukk meny",
|
||||
"unselect": "Avvelg",
|
||||
"skip": "Hopp over",
|
||||
"bulk_actions_mobile": "",
|
||||
"share": "",
|
||||
"download": ""
|
||||
"share": "Del",
|
||||
"download": "Last Ned"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ja",
|
||||
@@ -312,29 +315,29 @@
|
||||
},
|
||||
"page": {
|
||||
"create": "Opprett %{name}",
|
||||
"dashboard": "Dashbord",
|
||||
"dashboard": "Dashboard",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Noe gikk galt",
|
||||
"list": "%{Navn}",
|
||||
"list": "%{name}",
|
||||
"loading": "Laster",
|
||||
"not_found": "Ikke funnet",
|
||||
"not_found": "Ikke Funnet",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Ingen %{name} ennå.",
|
||||
"invite": "Vil du legge til en?"
|
||||
"empty": "Ingen %{name} enda.",
|
||||
"invite": "Ønsker du å legge til en?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Slipp noen filer for å laste opp, eller klikk for å velge en.",
|
||||
"upload_single": "Slipp en fil for å laste opp, eller klikk for å velge den."
|
||||
"upload_several": "Dra filer hit for å laste opp, eller klikk for å velge en.",
|
||||
"upload_single": "Dra en fil hit for å laste opp, eller klikk for å velge den."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Slipp noen bilder for å laste opp, eller klikk for å velge ett.",
|
||||
"upload_single": "Slipp et bilde for å laste opp, eller klikk for å velge det."
|
||||
"upload_several": "Dra bilder hit for å laste opp, eller klikk for å velge en.",
|
||||
"upload_single": "Dra et bilde hit for å laste opp, eller klikk for å velge den."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "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."
|
||||
"all_missing": "Finner ikke referansedata.",
|
||||
"many_missing": "Minst en av de tilhørende referansene ser ikke lenger ut til å være tilgjengelig.",
|
||||
"single_missing": "Tilhørende referanse ser ikke lenger ut til å være tilgjengelig."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Skjul passord",
|
||||
@@ -346,86 +349,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 vil slette dette elementet?",
|
||||
"delete_content": "Er du sikker på at du ønsker å slette dette elementet?",
|
||||
"delete_title": "Slett %{name} #%{id}",
|
||||
"details": "Detaljer",
|
||||
"error": "Det oppstod en klientfeil og forespørselen din kunne ikke fullføres.",
|
||||
"invalid_form": "Skjemaet er ikke gyldig. Vennligst se etter feil",
|
||||
"loading": "Siden lastes, bare et øyeblikk",
|
||||
"error": "En klient feil har oppstått og din forespørsel lot seg ikke gjennomføre.",
|
||||
"invalid_form": "Skjemaet er ikke gyldig. Vennligst se etter feil.",
|
||||
"loading": "Siden laster, vennligst vent.",
|
||||
"no": "Nei",
|
||||
"not_found": "Enten skrev du inn feil URL, eller så fulgte du en dårlig lenke.",
|
||||
"not_found": "Enten skrev du feil URL, eller så har du fulgt en dårlig link.",
|
||||
"yes": "Ja",
|
||||
"unsaved_changes": "Noen av endringene dine ble ikke lagret. Er du sikker på at du vil ignorere dem?"
|
||||
"unsaved_changes": "Noen av dine endringer ble ikke lagret. Er du sikker på at du ønsker å ignorere de?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Ingen resultater",
|
||||
"no_more_results": "Sidetallet %{page} er utenfor grensene. Prøv forrige side.",
|
||||
"page_out_of_boundaries": "Sidetall %{page} utenfor grensene",
|
||||
"page_out_from_end": "Kan ikke gå etter siste side",
|
||||
"page_out_from_begin": "Kan ikke gå før side 1",
|
||||
"no_more_results": "Sidenummeret %{page} er utenfor grensene. Prøv forrige side.",
|
||||
"page_out_of_boundaries": "Sidenummer %{page} er utenfor grensene",
|
||||
"page_out_from_end": "Kan ikke være etter siste side",
|
||||
"page_out_from_begin": "Kan ikke være før side 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
|
||||
"page_rows_per_page": "Elementer per side:",
|
||||
"next": "Neste",
|
||||
"prev": "Forrige",
|
||||
"skip_nav": "Hopp til innholdet"
|
||||
"skip_nav": "Hopp til innhold"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Element oppdatert |||| %{smart_count} elementer er oppdatert",
|
||||
"updated": "Element oppdatert |||| %{smart_count} elementer oppdatert",
|
||||
"created": "Element opprettet",
|
||||
"deleted": "Element slettet |||| %{smart_count} elementer slettet",
|
||||
"bad_item": "Feil element",
|
||||
"item_doesnt_exist": "Elementet eksisterer ikke",
|
||||
"http_error": "Serverkommunikasjonsfeil",
|
||||
"data_provider_error": "dataleverandørfeil. Sjekk konsollen for detaljer.",
|
||||
"i18n_error": "Kan ikke laste oversettelsene for det angitte språket",
|
||||
"canceled": "Handlingen avbrutt",
|
||||
"logged_out": "Økten din er avsluttet. Koble til på nytt.",
|
||||
"new_version": "Ny versjon tilgjengelig! Trykk Oppdater "
|
||||
"item_doesnt_exist": "Element eksisterer ikke",
|
||||
"http_error": "Kommunikasjonsfeil mot server",
|
||||
"data_provider_error": "dataProvider feil. Sjekk konsollet for feil.",
|
||||
"i18n_error": "Klarte ikke laste oversettelser for valgt språk.",
|
||||
"canceled": "Handling avbrutt",
|
||||
"logged_out": "Din sesjon er avsluttet, vennligst koble til på nytt.",
|
||||
"new_version": "Ny versjon tilgjengelig! Vennligst last siden på nytt."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Kolonner som skal vises",
|
||||
"layout": "Oppsett",
|
||||
"grid": "Nett",
|
||||
"table": "Bord"
|
||||
"columnsToDisplay": "Vis følgende kolonner",
|
||||
"layout": "Layout",
|
||||
"grid": "Rutenett",
|
||||
"table": "Tabell"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "Info",
|
||||
"transcodingDisabled": "Endring av transkodingskonfigurasjonen gjennom webgrensesnittet er deaktivert av sikkerhetsgrunner. Hvis du ønsker å endre (redigere eller legge til) transkodingsalternativer, start serveren på nytt med %{config}-konfigurasjonsalternativet.",
|
||||
"transcodingEnabled": "Navidrome kjører for øyeblikket med %{config}, noe som gjør det mulig å kjøre systemkommandoer fra transkodingsinnstillingene ved å bruke nettgrensesnittet. Vi anbefaler å deaktivere den av sikkerhetsgrunner og bare aktivere den når du konfigurerer alternativer for omkoding.",
|
||||
"note": "NOTAT",
|
||||
"transcodingDisabled": "Endringer på transkodingkonfigurasjon fra web grensesnittet er deaktivert grunnet sikkerhet. Hvis du ønsker å endre eller legge til transkodingsmuligheter, restart serveren med %{config} konfigurasjonsalternativ.",
|
||||
"transcodingEnabled": "Navidrome kjører for øyeblikket med %{config}, som gjør det mulig å kjøre systemkommandoer fra transkodingsinstillinger i web grensesnittet. Vi anbefaler å deaktivere denne muligheten av sikkerhetsårsaker og heller kun ha det aktivert under konfigurasjon av transkodingsmuligheter.",
|
||||
"songsAddedToPlaylist": "Lagt til 1 sang i spillelisten |||| Lagt til %{smart_count} sanger i spillelisten",
|
||||
"noPlaylistsAvailable": "Ingen tilgjengelig",
|
||||
"delete_user_title": "Slett bruker «%{name}»",
|
||||
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og 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",
|
||||
"delete_user_title": "Slett bruker '%{name}'",
|
||||
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og all tilhørlig data (inkludert spillelister og preferanser)?",
|
||||
"remove_missing_title": "Fjern manglende filer",
|
||||
"remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
|
||||
"notifications_blocked": "Du har blokkert notifikasjoner for denne nettsiden i din nettleser.",
|
||||
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsnotifikasjoner, eller så er du ikke tilkoblet Navidrome via https.",
|
||||
"lastfmLinkSuccess": "Last.fm er tilkoblet og scrobbling er aktivert",
|
||||
"lastfmLinkFailure": "Last.fm kunne ikke koble til",
|
||||
"lastfmUnlinkSuccess": "Last.fm er avkoblet og scrobbling er deaktivert",
|
||||
"lastfmUnlinkFailure": "Last.fm kunne ikke avkobles",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
|
||||
"openIn": {
|
||||
"lastfm": "Åpne i Last.fm",
|
||||
"musicbrainz": "Åpne i MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Les mer...",
|
||||
"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": ""
|
||||
"lastfmLink": "Les Mer...",
|
||||
"shareOriginalFormat": "Del i originalformat",
|
||||
"shareDialogTitle": "Del %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
|
||||
"shareSuccess": "URL kopiert til utklippstavle: %{url}",
|
||||
"shareFailure": "Error ved kopiering av URL %{url} til utklippstavle",
|
||||
"downloadDialogTitle": "Last ned %{resource} '%{name}' (%{size})",
|
||||
"downloadOriginalFormat": "Last ned i originalformat"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
"settings": "Innstillinger",
|
||||
"settings": "Instillinger",
|
||||
"version": "Versjon",
|
||||
"theme": "Tema",
|
||||
"personal": {
|
||||
@@ -434,81 +437,81 @@
|
||||
"theme": "Tema",
|
||||
"language": "Språk",
|
||||
"defaultView": "Standardvisning",
|
||||
"desktop_notifications": "Skrivebordsvarsler",
|
||||
"desktop_notifications": "Skrivebordsnotifikasjoner",
|
||||
"lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert",
|
||||
"lastfmScrobbling": "Scrobble til Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobble til ListenBrainz",
|
||||
"replaygain": "",
|
||||
"preAmp": "",
|
||||
"replaygain": "ReplayGain Mode",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"gain": {
|
||||
"none": "",
|
||||
"album": "",
|
||||
"track": ""
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
"none": "Deaktivert",
|
||||
"album": "Bruk Album Gain",
|
||||
"track": "Bruk Track Gain"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Album",
|
||||
"about": "Om",
|
||||
"playlists": "Spilleliste",
|
||||
"sharedPlaylists": "Delte spillelister"
|
||||
"playlists": "Spillelister",
|
||||
"sharedPlaylists": "Delte Spillelister",
|
||||
"about": "Om"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Spillekø",
|
||||
"playListsText": "Spill Av Kø",
|
||||
"openText": "Åpne",
|
||||
"closeText": "Lukk",
|
||||
"notContentText": "Ingen musikk",
|
||||
"clickToPlayText": "Klikk for å spille",
|
||||
"clickToPauseText": "Klikk for å sette på pause",
|
||||
"clickToPlayText": "Klikk for å avspille",
|
||||
"clickToPauseText": "Klikk for å pause",
|
||||
"nextTrackText": "Neste spor",
|
||||
"previousTrackText": "Forrige spor",
|
||||
"reloadText": "Last inn på nytt",
|
||||
"reloadText": "Last på nytt",
|
||||
"volumeText": "Volum",
|
||||
"toggleLyricText": "Veksle mellom tekster",
|
||||
"toggleLyricText": "Slå på/av sangtekster",
|
||||
"toggleMiniModeText": "Minimer",
|
||||
"destroyText": "Ødelegge",
|
||||
"downloadText": "nedlasting",
|
||||
"destroyText": "Ødelegg",
|
||||
"downloadText": "Last Ned",
|
||||
"removeAudioListsText": "Slett lydlister",
|
||||
"clickToDeleteText": "Klikk for å slette %{name}",
|
||||
"emptyLyricText": "Ingen sangtekster",
|
||||
"playModeText": {
|
||||
"order": "I rekkefølge",
|
||||
"orderLoop": "Gjenta",
|
||||
"singleLoop": "Gjenta engang",
|
||||
"shufflePlay": "Tilfeldig rekkefølge"
|
||||
"orderLoop": "Repeat",
|
||||
"singleLoop": "Repeat En",
|
||||
"shufflePlay": "Shuffle"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Hjemmeside",
|
||||
"source": "Kildekode",
|
||||
"featureRequests": "Funksjonsforespørsler",
|
||||
"lastInsightsCollection": "",
|
||||
"featureRequests": "Funksjonsforespørseler",
|
||||
"lastInsightsCollection": "Siste Innsamling av anonymisert forbruksdata",
|
||||
"insights": {
|
||||
"disabled": "",
|
||||
"waiting": ""
|
||||
"disabled": "Deaktivert",
|
||||
"waiting": "Venter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivitet",
|
||||
"totalScanned": "Totalt skannede mapper",
|
||||
"quickScan": "Rask skanning",
|
||||
"fullScan": "Full skanning",
|
||||
"serverUptime": "Serveroppetid",
|
||||
"totalScanned": "Antall mapper skannet",
|
||||
"quickScan": "Hurtigskann",
|
||||
"fullScan": "Full Skann",
|
||||
"serverUptime": "Server Oppetid",
|
||||
"serverDown": "OFFLINE"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome hurtigtaster",
|
||||
"title": "Navidrome Hurtigtaster",
|
||||
"hotkeys": {
|
||||
"show_help": "Vis denne hjelpen",
|
||||
"toggle_menu": "Bytt menysidelinje",
|
||||
"toggle_play": "Spill / Pause",
|
||||
"prev_song": "Forrige sang",
|
||||
"next_song": "Neste sang",
|
||||
"vol_up": "Volum opp",
|
||||
"vol_down": "Volum ned",
|
||||
"toggle_love": "Legg til dette sporet i favoritter",
|
||||
"current_song": ""
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"tags": "Дополнительные теги",
|
||||
"mappedTags": "Сопоставленные теги",
|
||||
"rawTags": "Исходные теги",
|
||||
"bitDepth": "Битовая глубина"
|
||||
"bitDepth": "Битовая глубина",
|
||||
"sampleRate": "Частота дискретизации (Гц)"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "В очередь",
|
||||
@@ -72,7 +73,7 @@
|
||||
"grouping": "Группирование",
|
||||
"media": "Медиа",
|
||||
"mood": "Настроение",
|
||||
"date": ""
|
||||
"date": "Дата записи"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Играть",
|
||||
|
||||
@@ -1,465 +1,517 @@
|
||||
{
|
||||
"languageName": "српски",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Песма |||| Песме",
|
||||
"fields": {
|
||||
"albumArtist": "Уметник албума",
|
||||
"duration": "Трајање",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Пуштано",
|
||||
"title": "Наслов",
|
||||
"artist": "Уметник",
|
||||
"album": "Албум",
|
||||
"path": "Путања фајла",
|
||||
"genre": "Жанр",
|
||||
"compilation": "Компилација",
|
||||
"year": "Година",
|
||||
"size": "Величина фајла",
|
||||
"updatedAt": "Ажурирано",
|
||||
"bitRate": "Битски проток",
|
||||
"channels": "Канала",
|
||||
"discSubtitle": "Поднаслов диска",
|
||||
"starred": "Омиљено",
|
||||
"comment": "Коментар",
|
||||
"rating": "Рејтинг",
|
||||
"quality": "Квалитет",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Последње пуштано",
|
||||
"createdAt": "Датум додавања"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Пусти касније",
|
||||
"playNow": "Пусти одмах",
|
||||
"addToPlaylist": "Додај у плејлисту",
|
||||
"shuffleAll": "Измешај све",
|
||||
"download": "Преузми",
|
||||
"playNext": "Пусти наредно",
|
||||
"info": "Прикажи инфо"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Албум |||| Албуми",
|
||||
"fields": {
|
||||
"albumArtist": "Уметник албума",
|
||||
"artist": "Уметник",
|
||||
"duration": "Трајање",
|
||||
"songCount": "Песме",
|
||||
"playCount": "Пуштано",
|
||||
"size": "Величина",
|
||||
"name": "Назив",
|
||||
"genre": "Жанр",
|
||||
"compilation": "Компилација",
|
||||
"year": "Година",
|
||||
"originalDate": "Оригинално",
|
||||
"releaseDate": "Објављено",
|
||||
"releases": "Издање|||| Издања",
|
||||
"released": "Објављено",
|
||||
"updatedAt": "Ажурирано",
|
||||
"comment": "Коментар",
|
||||
"rating": "Рејтинг",
|
||||
"createdAt": "Датум додавања"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Пусти",
|
||||
"playNext": "Пусти наредно",
|
||||
"addToQueue": "Пусти касније",
|
||||
"share": "Дели",
|
||||
"shuffle": "Измешај",
|
||||
"addToPlaylist": "Додај у плејлисту",
|
||||
"download": "Преузми",
|
||||
"info": "Прикажи инфо"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Све",
|
||||
"random": "Насумично",
|
||||
"recentlyAdded": "Додато недавно",
|
||||
"recentlyPlayed": "Пуштано недавно",
|
||||
"mostPlayed": "Најчешће пуштано",
|
||||
"starred": "Омиљено",
|
||||
"topRated": "Најбоље рангирано"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Уметник |||| Уметници",
|
||||
"fields": {
|
||||
"name": "Име",
|
||||
"albumCount": "Број албума",
|
||||
"songCount": "Број песама",
|
||||
"size": "Величина",
|
||||
"playCount": "Пуштано",
|
||||
"rating": "Рејтинг",
|
||||
"genre": "Жанр"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Корисник |||| Корисници",
|
||||
"fields": {
|
||||
"userName": "Корисничко име",
|
||||
"isAdmin": "Да ли је Админ",
|
||||
"lastLoginAt": "Последња пријава",
|
||||
"lastAccessAt": "Последњи приступ",
|
||||
"updatedAt": "Ажурирано",
|
||||
"name": "Име",
|
||||
"password": "Лозинка",
|
||||
"createdAt": "Креирана",
|
||||
"changePassword": "Измени лозинку?",
|
||||
"currentPassword": "Текућа лозинка",
|
||||
"newPassword": "Нова лозинка",
|
||||
"token": "Жетон"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Измене вашег имена ће постати видљиве након следеће пријаве"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Корисник креиран",
|
||||
"updated": "Корисник ажуриран",
|
||||
"deleted": "Корисник обрисан"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.",
|
||||
"clickHereForToken": "Кликните овде да преузмете свој жетон"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Плејер |||| Плејери",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"transcodingId": "Транскодирање",
|
||||
"maxBitRate": "Макс. битски проток",
|
||||
"client": "Клијент",
|
||||
"userName": "Корисничко име",
|
||||
"lastSeen": "последњи пут виђен",
|
||||
"reportRealPath": "Пријављуј реалну путању",
|
||||
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Транскодирање |||| Транскодирања",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"targetFormat": "Циљни формат",
|
||||
"defaultBitRate": "Подразумевани битски проток",
|
||||
"command": "Команда"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Плејлиста |||| Плејлисте",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"duration": "Трајање",
|
||||
"ownerName": "Власник",
|
||||
"public": "Јавна",
|
||||
"updatedAt": "Ажурирана",
|
||||
"createdAt": "Креирана",
|
||||
"songCount": "Песме",
|
||||
"comment": "Коментар",
|
||||
"sync": "Ауто-увоз",
|
||||
"path": "Увоз из"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Изабери плејлисту",
|
||||
"addNewPlaylist": "Креирај „%{name}”",
|
||||
"export": "Извоз",
|
||||
"makePublic": "Учини јавном",
|
||||
"makePrivate": "Учини приватном"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Додај дуплиране песме",
|
||||
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Радио |||| Радији",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"streamUrl": "URL тока",
|
||||
"homePageUrl": "URL почетне странице",
|
||||
"updatedAt": "Ажурирано",
|
||||
"createdAt": "Креирано"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Пусти одмах"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Дељење |||| Дељења",
|
||||
"fields": {
|
||||
"username": "Поделио",
|
||||
"url": "URL",
|
||||
"description": "Опис",
|
||||
"downloadable": "Допушта се преузимање?",
|
||||
"contents": "Садржај",
|
||||
"expiresAt": "Истиче",
|
||||
"lastVisitedAt": "Последњи пут посећено",
|
||||
"visitCount": "Број посета",
|
||||
"format": "Формат",
|
||||
"maxBitRate": "Макс. битски проток",
|
||||
"updatedAt": "Ажурирано",
|
||||
"createdAt": "Креирано"
|
||||
},
|
||||
"notifications": {
|
||||
},
|
||||
"actions": {
|
||||
}
|
||||
}
|
||||
"languageName": "српски",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Песма |||| Песме",
|
||||
"fields": {
|
||||
"album": "Албум",
|
||||
"albumArtist": "Уметник албума",
|
||||
"artist": "Уметник",
|
||||
"bitDepth": "Битова",
|
||||
"bitRate": "Битски проток",
|
||||
"bpm": "BPM",
|
||||
"channels": "Канала",
|
||||
"comment": "Коментар",
|
||||
"compilation": "Компилација",
|
||||
"createdAt": "Датум додавања",
|
||||
"discSubtitle": "Поднаслов диска",
|
||||
"duration": "Трајање",
|
||||
"genre": "Жанр",
|
||||
"grouping": "Груписање",
|
||||
"mappedTags": "Мапиране ознаке",
|
||||
"mood": "Расположење",
|
||||
"participants": "Додатни учесници",
|
||||
"path": "Путања фајла",
|
||||
"playCount": "Пуштано",
|
||||
"playDate": "Последње пуштано",
|
||||
"quality": "Квалитет",
|
||||
"rating": "Рејтинг",
|
||||
"rawTags": "Сирове ознаке",
|
||||
"size": "Величина фајла",
|
||||
"starred": "Омиљено",
|
||||
"tags": "Додатне ознаке",
|
||||
"title": "Наслов",
|
||||
"trackNumber": "#",
|
||||
"updatedAt": "Ажурирано",
|
||||
"year": "Година"
|
||||
},
|
||||
"actions": {
|
||||
"addToPlaylist": "Додај у плејлисту",
|
||||
"addToQueue": "Пусти касније",
|
||||
"download": "Преузми",
|
||||
"info": "Прикажи инфо",
|
||||
"playNext": "Пусти наредно",
|
||||
"playNow": "Пусти одмах",
|
||||
"shuffleAll": "Измешај све"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Хвала што сте инсталирали Navidrome!",
|
||||
"welcome2": "За почетак, креирајте админ корисника",
|
||||
"confirmPassword": "Потврдите лозинку",
|
||||
"buttonCreateAdmin": "Креирај админа",
|
||||
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
|
||||
"user_menu": "Профил",
|
||||
"username": "Корисничко име",
|
||||
"password": "Лозинка",
|
||||
"sign_in": "Пријави се",
|
||||
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
|
||||
"logout": "Одјави се"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Молимо вас да користите само слова и цифре",
|
||||
"passwordDoesNotMatch": "Лозинка се не подудара",
|
||||
"required": "Неопходно",
|
||||
"minLength": "Мора да буде барем %{min} карактера",
|
||||
"maxLength": "Мора да буде %{max} карактера или мање",
|
||||
"minValue": "Мора да буде барем %{min}",
|
||||
"maxValue": "Мора да буде %{max} или мање",
|
||||
"number": "Мора да буде број",
|
||||
"email": "Мора да буде исправна и-мејл адреса",
|
||||
"oneOf": "Мора да буде једно од: %{options}",
|
||||
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
|
||||
"unique": "Мора да буде јединствено",
|
||||
"url": "Мора да буде исправна URL адреса"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Додај филтер",
|
||||
"add": "Додај",
|
||||
"back": "Иди назад",
|
||||
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Откажи",
|
||||
"clear_input_value": "Обриши вредност",
|
||||
"clone": "Клонирај",
|
||||
"confirm": "Потврди",
|
||||
"create": "Креирај",
|
||||
"delete": "Обриши",
|
||||
"edit": "Уреди",
|
||||
"export": "Извези",
|
||||
"list": "Листа",
|
||||
"refresh": "Освежи",
|
||||
"remove_filter": "Уклони овај филтер",
|
||||
"remove": "Уклони",
|
||||
"save": "Сачувај",
|
||||
"search": "Тражи",
|
||||
"show": "Прикажи",
|
||||
"sort": "Сортирај",
|
||||
"undo": "Поништи",
|
||||
"expand": "Развиј",
|
||||
"close": "Затвори",
|
||||
"open_menu": "Отвори мени",
|
||||
"close_menu": "Затвори мени",
|
||||
"unselect": "Уклони избор",
|
||||
"skip": "Прескочи",
|
||||
"share": "Подели",
|
||||
"download": "Преузми"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Да",
|
||||
"false": "Не"
|
||||
},
|
||||
"page": {
|
||||
"create": "Креирај %{name}",
|
||||
"dashboard": "Контролна табла",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Нешто је пошло наопако",
|
||||
"list": "%{name}",
|
||||
"loading": "Учитава се",
|
||||
"not_found": "Није пронађено",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Још увек нема %{name}.",
|
||||
"invite": "Желите ли да се дода?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
|
||||
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
|
||||
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Не могу да се нађу подаци референци.",
|
||||
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
|
||||
"single_missing": "Изгледа да придружена референца више није доступна."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Сакриј лозинку",
|
||||
"toggle_hidden": "Прикажи лозинку"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "О програму",
|
||||
"are_you_sure": "Да ли сте сигурни?",
|
||||
"bulk_delete_content": "Да ли заиста желите да обришете %{name}? |||| Да ли заиста желите да обришете %{smart_count} ставке?",
|
||||
"bulk_delete_title": "Брисање %{name} |||| Брисање %{smart_count} %{name}",
|
||||
"delete_content": "Да ли заиста желите да обришете ову ставку?",
|
||||
"delete_title": "Брисање %{name} #%{id}",
|
||||
"details": "Детаљи",
|
||||
"error": "Дошло је до клијентске грешке и ваш захтев није могао да се изврши.",
|
||||
"invalid_form": "Формулар није исправан. Молимо вас да исправите грешке",
|
||||
"loading": "Страница се учитава, сачекајте мало",
|
||||
"no": "Не",
|
||||
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
|
||||
"yes": "Да",
|
||||
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Није пронађен ниједан резултат",
|
||||
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
|
||||
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
|
||||
"page_out_from_end": "Не може да се иде након последње странице",
|
||||
"page_out_from_begin": "Не може да се иде испред странице 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
|
||||
"page_rows_per_page": "Ставки по страници:",
|
||||
"next": "Наредна",
|
||||
"prev": "Претход",
|
||||
"skip_nav": "Прескочи на садржај"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано",
|
||||
"created": "Елемент је креиран",
|
||||
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
|
||||
"bad_item": "Неисправни елемент",
|
||||
"item_doesnt_exist": "Елемент не постоји",
|
||||
"http_error": "Грешка у комуникацији са сервером",
|
||||
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
|
||||
"i18n_error": "Не могу да се учитају преводи за наведени језик",
|
||||
"canceled": "Акција је отказана",
|
||||
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
|
||||
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Колоне за приказ",
|
||||
"layout": "Распоред",
|
||||
"grid": "Мрежа",
|
||||
"table": "Табела"
|
||||
}
|
||||
"album": {
|
||||
"name": "Албум |||| Албуми",
|
||||
"fields": {
|
||||
"albumArtist": "Уметник албума",
|
||||
"artist": "Уметник",
|
||||
"catalogNum": "Каталошки број",
|
||||
"comment": "Коментар",
|
||||
"compilation": "Компилација",
|
||||
"createdAt": "Датум додавања",
|
||||
"date": "Датум снимања",
|
||||
"duration": "Трајање",
|
||||
"genre": "Жанр",
|
||||
"grouping": "Груписање",
|
||||
"media": "Медијум",
|
||||
"mood": "Расположење",
|
||||
"name": "Назив",
|
||||
"originalDate": "Оригинално",
|
||||
"playCount": "Пуштано",
|
||||
"rating": "Рејтинг",
|
||||
"recordLabel": "Издавачка кућа",
|
||||
"releaseDate": "Објављено",
|
||||
"releaseType": "Тип",
|
||||
"released": "Објављено",
|
||||
"releases": "Издање|||| Издања",
|
||||
"size": "Величина",
|
||||
"songCount": "Песме",
|
||||
"updatedAt": "Ажурирано",
|
||||
"year": "Година"
|
||||
},
|
||||
"actions": {
|
||||
"addToPlaylist": "Додај у плејлисту",
|
||||
"addToQueue": "Пусти касније",
|
||||
"download": "Преузми",
|
||||
"info": "Прикажи инфо",
|
||||
"playAll": "Пусти",
|
||||
"playNext": "Пусти наредно",
|
||||
"share": "Дели",
|
||||
"shuffle": "Измешај"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Све",
|
||||
"mostPlayed": "Најчешће пуштано",
|
||||
"random": "Насумично",
|
||||
"recentlyAdded": "Додато недавно",
|
||||
"recentlyPlayed": "Пуштано недавно",
|
||||
"starred": "Омиљено",
|
||||
"topRated": "Најбоље рангирано"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "НАПОМЕНА",
|
||||
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
|
||||
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.",
|
||||
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
|
||||
"noPlaylistsAvailable": "Није доступна ниједна",
|
||||
"delete_user_title": "Брисање корисника ’%{name}’",
|
||||
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
|
||||
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
|
||||
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
|
||||
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
|
||||
"lastfmLinkFailure": "Last.fm није могао да се повеже",
|
||||
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
|
||||
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
|
||||
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
|
||||
"openIn": {
|
||||
"lastfm": "Отвори у Last.fm",
|
||||
"musicbrainz": "Отвори у MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Прочитај још...",
|
||||
"shareOriginalFormat": "Подели у оригиналном формату",
|
||||
"shareDialogTitle": "Подели %{resource} ’%{name}’",
|
||||
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
|
||||
"shareSuccess": "URL је копиран у клипборд: %{url}",
|
||||
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
|
||||
"downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})",
|
||||
"downloadOriginalFormat": "Преузми у оригиналном формату"
|
||||
"artist": {
|
||||
"name": "Уметник |||| Уметници",
|
||||
"fields": {
|
||||
"albumCount": "Број албума",
|
||||
"genre": "Жанр",
|
||||
"name": "Назив",
|
||||
"playCount": "Пуштано",
|
||||
"rating": "Рејтинг",
|
||||
"role": "Улога",
|
||||
"size": "Величина",
|
||||
"songCount": "Број песама"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Уметник албума |||| Уметници албума",
|
||||
"arranger": "Аранжер |||| Аранжери",
|
||||
"artist": "Уметник |||| Уметници",
|
||||
"composer": "Композитор |||| Композитори",
|
||||
"conductor": "Диригент |||| Диригенти",
|
||||
"director": "Режисер |||| Режисери",
|
||||
"djmixer": "Ди-џеј миксер |||| Ди-џеј миксер",
|
||||
"engineer": "Инжењер |||| Инжењери",
|
||||
"lyricist": "Текстописац |||| Текстописци",
|
||||
"mixer": "Миксер |||| Миксери",
|
||||
"performer": "Извођач |||| Извођачи",
|
||||
"producer": "Продуцент |||| Продуценти",
|
||||
"remixer": "Ремиксер |||| Ремиксери"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"library": "Библиотека",
|
||||
"settings": "Подешавања",
|
||||
"version": "Верзија",
|
||||
"theme": "Тема",
|
||||
"personal": {
|
||||
"name": "Лична",
|
||||
"options": {
|
||||
"theme": "Тема",
|
||||
"language": "Језик",
|
||||
"defaultView": "Подразумевани поглед",
|
||||
"desktop_notifications": "Десктоп обавештења",
|
||||
"lastfmScrobbling": "Скроблуј на Last.fm",
|
||||
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
|
||||
"replaygain": "ReplayGain режим",
|
||||
"preAmp": "ReplayGain претпојачање (dB)",
|
||||
"gain": {
|
||||
"none": "Искључено",
|
||||
"album": "Користи Album појачање",
|
||||
"track": "Користи Track појачање"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Албуми",
|
||||
"playlists": "Плејлисте",
|
||||
"sharedPlaylists": "Дељене плејлисте",
|
||||
"about": "О"
|
||||
"user": {
|
||||
"name": "Корисник |||| Корисници",
|
||||
"fields": {
|
||||
"changePassword": "Измени лозинку?",
|
||||
"createdAt": "Креирана",
|
||||
"currentPassword": "Текућа лозинка",
|
||||
"isAdmin": "Да ли је Админ",
|
||||
"lastAccessAt": "Последњи приступ",
|
||||
"lastLoginAt": "Последња пријава",
|
||||
"name": "Назив",
|
||||
"newPassword": "Нова лозинка",
|
||||
"password": "Лозинка",
|
||||
"token": "Жетон",
|
||||
"updatedAt": "Ажурирано",
|
||||
"userName": "Корисничко име"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Измене вашег имена ће постати видљиве након следеће пријаве"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Корисник креиран",
|
||||
"deleted": "Корисник обрисан",
|
||||
"updated": "Корисник ажуриран"
|
||||
},
|
||||
"message": {
|
||||
"clickHereForToken": "Кликните овде да преузмете свој жетон",
|
||||
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон."
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Ред за пуштање",
|
||||
"openText": "Отвори",
|
||||
"closeText": "Затвори",
|
||||
"notContentText": "Нема музике",
|
||||
"clickToPlayText": "Кликни за пуштање",
|
||||
"clickToPauseText": "Кликни за паузирање",
|
||||
"nextTrackText": "Наредна нумера",
|
||||
"previousTrackText": "Претходна нумера",
|
||||
"reloadText": "Поново учитај",
|
||||
"volumeText": "Јачина",
|
||||
"toggleLyricText": "Укљ./Искљ. стихове",
|
||||
"toggleMiniModeText": "Умањи",
|
||||
"destroyText": "Уништи",
|
||||
"downloadText": "Преузми",
|
||||
"removeAudioListsText": "Обриши аудио листе",
|
||||
"clickToDeleteText": "Кликните да обришете %{name}",
|
||||
"emptyLyricText": "Нема стихова",
|
||||
"playModeText": {
|
||||
"order": "По редоследу",
|
||||
"orderLoop": "Понови",
|
||||
"singleLoop": "Понови једну",
|
||||
"shufflePlay": "Промешано"
|
||||
}
|
||||
"name": "Плејер |||| Плејери",
|
||||
"fields": {
|
||||
"client": "Клијент",
|
||||
"lastSeen": "Последњи пут виђен",
|
||||
"maxBitRate": "Макс. битски проток",
|
||||
"name": "Назив",
|
||||
"reportRealPath": "Пријављуј реалну путању",
|
||||
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе",
|
||||
"transcodingId": "Транскодирање",
|
||||
"userName": "Корисничко име"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Почетна страница",
|
||||
"source": "Изворни кôд",
|
||||
"featureRequests": "Захтеви за функцијама"
|
||||
}
|
||||
"transcoding": {
|
||||
"name": "Транскодирање |||| Транскодирања",
|
||||
"fields": {
|
||||
"command": "Команда",
|
||||
"defaultBitRate": "Подразумевани битски проток",
|
||||
"name": "Назив",
|
||||
"targetFormat": "Циљни формат"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Активност",
|
||||
"totalScanned": "Укупан број скенираних фолдера",
|
||||
"quickScan": "Брзо скенирање",
|
||||
"fullScan": "Комплетно скенирање",
|
||||
"serverUptime": "Сервер се извршава",
|
||||
"serverDown": "ВАН МРЕЖЕ"
|
||||
"playlist": {
|
||||
"name": "Плејлиста |||| Плејлисте",
|
||||
"fields": {
|
||||
"comment": "Коментар",
|
||||
"createdAt": "Креирана",
|
||||
"duration": "Трајање",
|
||||
"name": "Назив",
|
||||
"ownerName": "Власник",
|
||||
"path": "Увоз из",
|
||||
"public": "Јавна",
|
||||
"songCount": "Песме",
|
||||
"sync": "Ауто-увоз",
|
||||
"updatedAt": "Ажурирано"
|
||||
},
|
||||
"actions": {
|
||||
"addNewPlaylist": "Креирај „%{name}”",
|
||||
"export": "Извези",
|
||||
"makePrivate": "Учини приватном",
|
||||
"makePublic": "Учини јавном",
|
||||
"selectPlaylist": "Изабери плејлисту"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Додај дуплиране песме",
|
||||
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome пречице",
|
||||
"hotkeys": {
|
||||
"show_help": "Прикажи ову помоћ",
|
||||
"toggle_menu": "Укљ./Искљ. бочну траку менија",
|
||||
"toggle_play": "Пусти / Паузирај",
|
||||
"prev_song": "Претходна песма",
|
||||
"next_song": "Наредна песма",
|
||||
"current_song": "Иди на текућу песму",
|
||||
"vol_up": "Појачај",
|
||||
"vol_down": "Утишај",
|
||||
"toggle_love": "Додај ову нумеру у омиљене"
|
||||
}
|
||||
"radio": {
|
||||
"name": "Радио |||| Радији",
|
||||
"fields": {
|
||||
"createdAt": "Креирана",
|
||||
"homePageUrl": "URL почетне странице",
|
||||
"name": "Назив",
|
||||
"streamUrl": "URL тока",
|
||||
"updatedAt": "Ажурирано"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Пусти одмах"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Дељење |||| Дељења",
|
||||
"fields": {
|
||||
"contents": "Садржај",
|
||||
"createdAt": "Креирано",
|
||||
"description": "Опис",
|
||||
"downloadable": "Допушта се преузимање?",
|
||||
"expiresAt": "Истиче",
|
||||
"format": "Формат",
|
||||
"lastVisitedAt": "Последњи пут посећено",
|
||||
"maxBitRate": "Макс. битски проток",
|
||||
"updatedAt": "Ажурирано",
|
||||
"url": "URL",
|
||||
"username": "Поделио",
|
||||
"visitCount": "Број посета"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Фајл који недостаје|||| Фајлови који недостају",
|
||||
"empty": "Нема фајлова који недостају",
|
||||
"fields": {
|
||||
"path": "Путања",
|
||||
"size": "Величина",
|
||||
"updatedAt": "Нестао дана"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Уклони"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Фајл који недостаје, или више њих, је уклоњен"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
|
||||
"buttonCreateAdmin": "Креирај админа",
|
||||
"confirmPassword": "Потврдите лозинку",
|
||||
"insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите",
|
||||
"logout": "Одјави се",
|
||||
"password": "Лозинка",
|
||||
"sign_in": "Пријави се",
|
||||
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
|
||||
"user_menu": "Профил",
|
||||
"username": "Корисничко име",
|
||||
"welcome1": "Хвала што сте инсталирали Navidrome!",
|
||||
"welcome2": "За почетак, креирајте админ корисника"
|
||||
},
|
||||
"validation": {
|
||||
"email": "Мора да буде исправна и-мејл адреса",
|
||||
"invalidChars": "Молимо вас да користите само слова и цифре",
|
||||
"maxLength": "Мора да буде %{max} карактера или мање",
|
||||
"maxValue": "Мора да буде %{max} или мање",
|
||||
"minLength": "Мора да буде барем %{min} карактера",
|
||||
"minValue": "Мора да буде барем %{min}",
|
||||
"number": "Мора да буде број",
|
||||
"oneOf": "Мора да буде једно од: %{options}",
|
||||
"passwordDoesNotMatch": "Лозинка се не подудара",
|
||||
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
|
||||
"required": "Неопходно",
|
||||
"unique": "Мора да буде јединствено",
|
||||
"url": "Мора да буде исправна URL адреса"
|
||||
},
|
||||
"action": {
|
||||
"add": "Додај",
|
||||
"add_filter": "Додај филтер",
|
||||
"back": "Иди назад",
|
||||
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Откажи",
|
||||
"clear_input_value": "Обриши вредност",
|
||||
"clone": "Клонирај",
|
||||
"close": "Затвори",
|
||||
"close_menu": "Затвори мени",
|
||||
"confirm": "Потврди",
|
||||
"create": "Креирај",
|
||||
"delete": "Обриши",
|
||||
"download": "Преузми",
|
||||
"edit": "Уреди",
|
||||
"expand": "Развиј",
|
||||
"export": "Извези",
|
||||
"list": "Листа",
|
||||
"open_menu": "Отвори мени",
|
||||
"refresh": "Освежи",
|
||||
"remove": "Уклони",
|
||||
"remove_filter": "Уклони овај филтер",
|
||||
"save": "Сачувај",
|
||||
"search": "Тражи",
|
||||
"share": "Дели",
|
||||
"show": "Прикажи",
|
||||
"skip": "Прескочи",
|
||||
"sort": "Сортирај",
|
||||
"undo": "Поништи",
|
||||
"unselect": "Уклони избор"
|
||||
},
|
||||
"boolean": {
|
||||
"false": "Не",
|
||||
"true": "Да"
|
||||
},
|
||||
"page": {
|
||||
"create": "Креирај %{name}",
|
||||
"dashboard": "Контролна табла",
|
||||
"edit": "%{name} #%{id}",
|
||||
"empty": "Још увек нема %{name}.",
|
||||
"error": "Нешто је пошло наопако",
|
||||
"invite": "Желите ли да се дода?",
|
||||
"list": "%{name}",
|
||||
"loading": "Учитава се",
|
||||
"not_found": "Није пронађено",
|
||||
"show": "%{name} #%{id}"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
|
||||
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
|
||||
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
|
||||
},
|
||||
"password": {
|
||||
"toggle_hidden": "Прикажи лозинку",
|
||||
"toggle_visible": "Сакриј лозинку"
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Не могу да се нађу подаци референци.",
|
||||
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
|
||||
"single_missing": "Изгледа да придружена референца више није доступна."
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "О",
|
||||
"are_you_sure": "Да ли сте сигурни?",
|
||||
"bulk_delete_content": "Да ли заиста желите да обришете %{name}? |||| Да ли заиста желите да обришете %{smart_count} ставке?",
|
||||
"bulk_delete_title": "Брисање %{name} |||| Брисање %{smart_count} %{name}",
|
||||
"delete_content": "Да ли заиста желите да обришете ову ставку?",
|
||||
"delete_title": "Брисање %{name} #%{id}",
|
||||
"details": "Детаљи",
|
||||
"error": "Дошло је до клијентске грешке и ваш захтев није могао да се изврши.",
|
||||
"invalid_form": "Формулар није исправан. Молимо вас да исправите грешке",
|
||||
"loading": "Страница се учитава, сачекајте мало",
|
||||
"no": "Не",
|
||||
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
|
||||
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?",
|
||||
"yes": "Да"
|
||||
},
|
||||
"navigation": {
|
||||
"next": "Наредна",
|
||||
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
|
||||
"no_results": "Није пронађен ниједан резултат",
|
||||
"page_out_from_begin": "Не може да се иде испред странице 1",
|
||||
"page_out_from_end": "Не може да се иде након последње странице",
|
||||
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
|
||||
"page_rows_per_page": "Ставки по страници:",
|
||||
"prev": "Претход",
|
||||
"skip_nav": "Прескочи на садржај"
|
||||
},
|
||||
"notification": {
|
||||
"bad_item": "Неисправни елемент",
|
||||
"canceled": "Акција је отказана",
|
||||
"created": "Елемент је креиран",
|
||||
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
|
||||
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
|
||||
"http_error": "Грешка у комуникацији са сервером",
|
||||
"i18n_error": "Не могу да се учитају преводи за наведени језик",
|
||||
"item_doesnt_exist": "Елемент не постоји",
|
||||
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
|
||||
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.",
|
||||
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано"
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Колоне за приказ",
|
||||
"grid": "Мрежа",
|
||||
"layout": "Распоред",
|
||||
"table": "Табела"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
|
||||
"delete_user_title": "Брисање корисника ’%{name}’",
|
||||
"downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})",
|
||||
"downloadOriginalFormat": "Преузми у оригиналном формату",
|
||||
"lastfmLink": "Прочитај још...",
|
||||
"lastfmLinkFailure": "Last.fm није могао да се повеже",
|
||||
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
|
||||
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
|
||||
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
|
||||
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
|
||||
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
|
||||
"noPlaylistsAvailable": "Није доступна ниједна",
|
||||
"note": "НАПОМЕНА",
|
||||
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
|
||||
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
|
||||
"openIn": {
|
||||
"lastfm": "Отвори у Last.fm",
|
||||
"musicbrainz": "Отвори у MusicBrainz"
|
||||
},
|
||||
"remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.",
|
||||
"remove_missing_title": "Уклони фајлове који недостају",
|
||||
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
|
||||
"shareDialogTitle": "Подели %{resource} ’%{name}’",
|
||||
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
|
||||
"shareOriginalFormat": "Подели у оригиналном формату",
|
||||
"shareSuccess": "URL је копиран у клипборд: %{url}",
|
||||
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
|
||||
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
|
||||
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања."
|
||||
},
|
||||
"menu": {
|
||||
"about": "О",
|
||||
"albumList": "Албуми",
|
||||
"library": "Библиотека",
|
||||
"personal": {
|
||||
"name": "Лична",
|
||||
"options": {
|
||||
"defaultView": "Подразумевани поглед",
|
||||
"desktop_notifications": "Десктоп обавештења",
|
||||
"gain": {
|
||||
"album": "Користи Album појачање",
|
||||
"none": "Искључено",
|
||||
"track": "Користи Track појачање"
|
||||
},
|
||||
"language": "Језик",
|
||||
"lastfmNotConfigured": "Није подешен Last.fm API-кључ",
|
||||
"lastfmScrobbling": "Скроблуј на Last.fm",
|
||||
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
|
||||
"preAmp": "ReplayGain претпојачање (dB)",
|
||||
"replaygain": "ReplayGain режим",
|
||||
"theme": "Тема"
|
||||
}
|
||||
},
|
||||
"playlists": "Плејлисте",
|
||||
"settings": "Подешавања",
|
||||
"sharedPlaylists": "Дељене плејлисте",
|
||||
"theme": "Тема",
|
||||
"version": "Верзија"
|
||||
},
|
||||
"player": {
|
||||
"clickToDeleteText": "Кликните да обришете %{name}",
|
||||
"clickToPauseText": "Кликни за паузирање",
|
||||
"clickToPlayText": "Кликни за пуштање",
|
||||
"closeText": "Затвори",
|
||||
"destroyText": "Уништи",
|
||||
"downloadText": "Преузми",
|
||||
"emptyLyricText": "Нема стихова",
|
||||
"nextTrackText": "Наредна нумера",
|
||||
"notContentText": "Нема музике",
|
||||
"openText": "Отвори",
|
||||
"playListsText": "Ред за пуштање",
|
||||
"playModeText": {
|
||||
"order": "По редоследу",
|
||||
"orderLoop": "Понови",
|
||||
"shufflePlay": "Измешај",
|
||||
"singleLoop": "Понови једну"
|
||||
},
|
||||
"previousTrackText": "Претходна нумера",
|
||||
"reloadText": "Поново учитај",
|
||||
"removeAudioListsText": "Обриши аудио листе",
|
||||
"toggleLyricText": "Укљ./Искљ. стихове",
|
||||
"toggleMiniModeText": "Умањи",
|
||||
"volumeText": "Јачина"
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"featureRequests": "Захтеви за функцијама",
|
||||
"homepage": "Почетна страница",
|
||||
"insights": {
|
||||
"disabled": "Искључено",
|
||||
"waiting": "Чека се"
|
||||
},
|
||||
"lastInsightsCollection": "Последња колекција увида",
|
||||
"source": "Изворни кôд"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"fullScan": "Комплетно скенирање",
|
||||
"quickScan": "Брзо скенирање",
|
||||
"serverDown": "ВАН МРЕЖЕ",
|
||||
"serverUptime": "Сервер се извршава",
|
||||
"title": "Активност",
|
||||
"totalScanned": "Укупан број скенираних фолдера"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome пречице",
|
||||
"hotkeys": {
|
||||
"current_song": "Иди на текућу песму",
|
||||
"next_song": "Наредна песма",
|
||||
"prev_song": "Претходна песма",
|
||||
"show_help": "Прикажи ову помоћ",
|
||||
"toggle_love": "Додај ову нумеру у омиљене",
|
||||
"toggle_menu": "Укљ./Искљ. бочну траку менија",
|
||||
"toggle_play": "Пусти / Паузирај",
|
||||
"vol_down": "Утишај",
|
||||
"vol_up": "Појачај"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"tags": "Ek Etiketler",
|
||||
"mappedTags": "Eşlenen etiketler",
|
||||
"rawTags": "Ham etiketler",
|
||||
"bitDepth": "Bit derinliği"
|
||||
"bitDepth": "Bit derinliği",
|
||||
"sampleRate": "Örnekleme Oranı"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Oynatma Sırasına Ekle",
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
"artist": "歌手",
|
||||
"album": "专辑",
|
||||
"path": "文件路径",
|
||||
"genre": "类型",
|
||||
"genre": "流派",
|
||||
"compilation": "合辑",
|
||||
"year": "发行年份",
|
||||
"size": "文件大小",
|
||||
"updatedAt": "更新于",
|
||||
"bitRate": "比特率",
|
||||
"bitDepth": "比特深度",
|
||||
"channels": "声道",
|
||||
"discSubtitle": "字幕",
|
||||
"starred": "收藏",
|
||||
"comment": "注释",
|
||||
@@ -25,8 +27,13 @@
|
||||
"quality": "品质",
|
||||
"bpm": "BPM",
|
||||
"playDate": "最后一次播放",
|
||||
"channels": "声道",
|
||||
"createdAt": "创建于"
|
||||
"createdAt": "创建于",
|
||||
"grouping": "分组",
|
||||
"mood": "情绪",
|
||||
"participants": "其他参与人员",
|
||||
"tags": "附加标签",
|
||||
"mappedTags": "映射标签",
|
||||
"rawTags": "原始标签"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "加入播放列表",
|
||||
@@ -46,29 +53,36 @@
|
||||
"duration": "时长",
|
||||
"songCount": "歌曲数量",
|
||||
"playCount": "播放次数",
|
||||
"size": "文件大小",
|
||||
"name": "名称",
|
||||
"genre": "类型",
|
||||
"genre": "流派",
|
||||
"compilation": "合辑",
|
||||
"year": "发行年份",
|
||||
"date": "录制日期",
|
||||
"originalDate": "原始日期",
|
||||
"releaseDate": "发⾏日期",
|
||||
"releases": "发⾏",
|
||||
"released": "已发⾏",
|
||||
"updatedAt": "更新于",
|
||||
"comment": "注释",
|
||||
"rating": "评分",
|
||||
"createdAt": "创建于",
|
||||
"size": "文件大小",
|
||||
"originalDate": "原始日期",
|
||||
"releaseDate": "发⾏日期",
|
||||
"releases": "发⾏",
|
||||
"released": "已发⾏"
|
||||
"recordLabel": "厂牌",
|
||||
"catalogNum": "目录编号",
|
||||
"releaseType": "发行类型",
|
||||
"grouping": "分组",
|
||||
"media": "媒体类型",
|
||||
"mood": "情绪"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "立即播放",
|
||||
"playNext": "下首播放",
|
||||
"addToQueue": "加入播放列表",
|
||||
"share": "分享",
|
||||
"shuffle": "随机播放",
|
||||
"addToPlaylist": "加入歌单",
|
||||
"download": "下载",
|
||||
"info": "查看信息",
|
||||
"share": "分享"
|
||||
"info": "查看信息"
|
||||
},
|
||||
"lists": {
|
||||
"all": "所有",
|
||||
@@ -86,10 +100,26 @@
|
||||
"name": "名称",
|
||||
"albumCount": "专辑数",
|
||||
"songCount": "歌曲数",
|
||||
"size": "文件大小",
|
||||
"playCount": "播放次数",
|
||||
"rating": "评分",
|
||||
"genre": "类型",
|
||||
"size": "文件大小"
|
||||
"genre": "流派",
|
||||
"role": "参与角色"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "专辑歌手",
|
||||
"artist": "歌手",
|
||||
"composer": "作曲",
|
||||
"conductor": "指挥",
|
||||
"lyricist": "作词",
|
||||
"arranger": "编曲",
|
||||
"producer": "制作人",
|
||||
"director": "总监",
|
||||
"engineer": "工程师",
|
||||
"mixer": "混音师",
|
||||
"remixer": "重混师",
|
||||
"djmixer": "DJ混音师",
|
||||
"performer": "演奏家"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -98,6 +128,7 @@
|
||||
"userName": "用户名",
|
||||
"isAdmin": "是否管理员",
|
||||
"lastLoginAt": "上次登录",
|
||||
"lastAccessAt": "上次访问",
|
||||
"updatedAt": "更新于",
|
||||
"name": "名称",
|
||||
"password": "密码",
|
||||
@@ -108,7 +139,7 @@
|
||||
"token": "令牌"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "你名字的更改将在下次登录生效"
|
||||
"name": "名称的更改将在下次登录时生效"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "用户已创建",
|
||||
@@ -187,6 +218,7 @@
|
||||
"username": "分享者",
|
||||
"url": "链接",
|
||||
"description": "描述",
|
||||
"downloadable": "是否允许下载?",
|
||||
"contents": "目录",
|
||||
"expiresAt": "过期于",
|
||||
"lastVisitedAt": "上次访问于",
|
||||
@@ -194,8 +226,24 @@
|
||||
"format": "格式",
|
||||
"maxBitRate": "最大比特率",
|
||||
"updatedAt": "更新于",
|
||||
"createdAt": "创建于",
|
||||
"downloadable": "是否允许下载"
|
||||
"createdAt": "创建于"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "丢失文件",
|
||||
"empty": "无丢失文件",
|
||||
"fields": {
|
||||
"path": "路径",
|
||||
"size": "文件大小",
|
||||
"updatedAt": "丢失于"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "移除"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "丢失文件已移除"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,7 +259,8 @@
|
||||
"password": "密码",
|
||||
"sign_in": "登录",
|
||||
"sign_in_error": "验证失败,请重试",
|
||||
"logout": "注销"
|
||||
"logout": "注销",
|
||||
"insightsCollectionNote": "Navidrome 会收集匿名使用数据以协助改进项目。\n点击[此处]了解详情或选择退出。"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "请使用字母和数字",
|
||||
@@ -233,6 +282,7 @@
|
||||
"add": "添加",
|
||||
"back": "返回",
|
||||
"bulk_actions": "选中 %{smart_count} 项",
|
||||
"bulk_actions_mobile": "%{smart_count}",
|
||||
"cancel": "取消",
|
||||
"clear_input_value": "清除",
|
||||
"clone": "复制",
|
||||
@@ -256,7 +306,6 @@
|
||||
"close_menu": "关闭菜单",
|
||||
"unselect": "未选择",
|
||||
"skip": "跳过",
|
||||
"bulk_actions_mobile": "%{smart_count}",
|
||||
"share": "分享",
|
||||
"download": "下载"
|
||||
},
|
||||
@@ -351,29 +400,31 @@
|
||||
"noPlaylistsAvailable": "没有有效的歌单",
|
||||
"delete_user_title": "删除用户 %{name}",
|
||||
"delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?",
|
||||
"remove_missing_title": "移除丢失文件",
|
||||
"remove_missing_content": "您确定要将选中的丢失文件从数据库中永久移除吗?此操作将删除所有相关信息,包括播放次数和评分。",
|
||||
"notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知",
|
||||
"notifications_not_available": "此浏览器不支持桌面通知",
|
||||
"lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录",
|
||||
"lastfmLinkFailure": "Last.fm 无法关联",
|
||||
"lastfmUnlinkSuccess": "已成功解除与 Last.fm 的链接,且喜好记录已禁用",
|
||||
"lastfmUnlinkFailure": "Last.fm 无法取消关联",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录",
|
||||
"listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}",
|
||||
"listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联",
|
||||
"openIn": {
|
||||
"lastfm": "在 Last.fm 中打开",
|
||||
"musicbrainz": "在 MusicBrainz 中打开"
|
||||
},
|
||||
"lastfmLink": "查看更多…",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录",
|
||||
"listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}",
|
||||
"listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联",
|
||||
"downloadOriginalFormat": "下载原始格式",
|
||||
"shareOriginalFormat": "分享原始格式",
|
||||
"shareDialogTitle": "分享 %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "分享 %{smart_count} 个 %{resource}",
|
||||
"shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter",
|
||||
"shareSuccess": "分享链接已复制: %{url}",
|
||||
"shareFailure": "分享链接复制失败: %{url}",
|
||||
"downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter"
|
||||
"downloadOriginalFormat": "下载原始格式"
|
||||
},
|
||||
"menu": {
|
||||
"library": "曲库",
|
||||
@@ -387,6 +438,7 @@
|
||||
"language": "语言",
|
||||
"defaultView": "默认界面",
|
||||
"desktop_notifications": "桌面通知",
|
||||
"lastfmNotConfigured": "没有配置 Last.fm 的 API-Key",
|
||||
"lastfmScrobbling": "启用 Last.fm 的喜好记录",
|
||||
"listenBrainzScrobbling": "启用 ListenBrainz 的喜好记录",
|
||||
"replaygain": "回放增益",
|
||||
@@ -399,9 +451,9 @@
|
||||
}
|
||||
},
|
||||
"albumList": "专辑",
|
||||
"about": "关于",
|
||||
"playlists": "歌单",
|
||||
"sharedPlaylists": "共享的歌单"
|
||||
"sharedPlaylists": "共享的歌单",
|
||||
"about": "关于"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "播放列表",
|
||||
@@ -432,7 +484,12 @@
|
||||
"links": {
|
||||
"homepage": "主页",
|
||||
"source": "源代码",
|
||||
"featureRequests": "功能需求"
|
||||
"featureRequests": "功能需求",
|
||||
"lastInsightsCollection": " 最近的分析收集",
|
||||
"insights": {
|
||||
"disabled": "禁用",
|
||||
"waiting": "等待"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -451,10 +508,10 @@
|
||||
"toggle_play": "播放/暂停",
|
||||
"prev_song": "上一首歌",
|
||||
"next_song": "下一首歌",
|
||||
"current_song": "转到当前播放",
|
||||
"vol_up": "增大音量",
|
||||
"vol_down": "减小音量",
|
||||
"toggle_love": "添加/移除星标",
|
||||
"current_song": "转到当前播放"
|
||||
"toggle_love": "添加/移除星标"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ main:
|
||||
bpm:
|
||||
aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ]
|
||||
lyrics:
|
||||
aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics ]
|
||||
aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics, unsyncedlyrics ]
|
||||
maxLength: 32768
|
||||
type: pair # ex: lyrics:eng, lyrics:xxx
|
||||
comment:
|
||||
|
||||
466
scanner/README.md
Normal file
466
scanner/README.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# Navidrome Scanner: Technical Overview
|
||||
|
||||
This document provides a comprehensive technical explanation of Navidrome's music library scanner system.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The Navidrome scanner is built on a multi-phase pipeline architecture designed for efficient processing of music files. It systematically traverses file system directories, processes metadata, and maintains a database representation of the music library. A key performance feature is that some phases run sequentially while others execute in parallel.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "Scanner Execution Flow"
|
||||
Controller[Scanner Controller] --> Scanner[Scanner Implementation]
|
||||
|
||||
Scanner --> Phase1[Phase 1: Folders Scan]
|
||||
Phase1 --> Phase2[Phase 2: Missing Tracks]
|
||||
|
||||
Phase2 --> ParallelPhases
|
||||
|
||||
subgraph ParallelPhases["Parallel Execution"]
|
||||
Phase3[Phase 3: Refresh Albums]
|
||||
Phase4[Phase 4: Playlist Import]
|
||||
end
|
||||
|
||||
ParallelPhases --> FinalSteps[Final Steps: GC + Stats]
|
||||
end
|
||||
|
||||
%% Triggers that can initiate a scan
|
||||
FileChanges[File System Changes] -->|Detected by| Watcher[Filesystem Watcher]
|
||||
Watcher -->|Triggers| Controller
|
||||
|
||||
ScheduledJob[Scheduled Job] -->|Based on Scanner.Schedule| Controller
|
||||
ServerStartup[Server Startup] -->|If Scanner.ScanOnStartup=true| Controller
|
||||
ManualTrigger[Manual Scan via UI/API] -->|Admin user action| Controller
|
||||
CLICommand[Command Line: navidrome scan] -->|Direct invocation| Controller
|
||||
PIDChange[PID Configuration Change] -->|Forces full scan| Controller
|
||||
DBMigration[Database Migration] -->|May require full scan| Controller
|
||||
|
||||
Scanner -.->|Alternative| External[External Scanner Process]
|
||||
```
|
||||
|
||||
The execution flow shows that Phases 1 and 2 run sequentially, while Phases 3 and 4 execute in parallel to maximize performance before the final processing steps.
|
||||
|
||||
## Core Components
|
||||
|
||||
### Scanner Controller (`controller.go`)
|
||||
|
||||
This is the entry point for all scanning operations. It provides:
|
||||
|
||||
- Public API for initiating scans and checking scan status
|
||||
- Event broadcasting to notify clients about scan progress
|
||||
- Serialization of scan operations (prevents concurrent scans)
|
||||
- Progress tracking and monitoring
|
||||
- Error collection and reporting
|
||||
|
||||
```go
|
||||
type Scanner interface {
|
||||
// ScanAll starts a full scan of the music library. This is a blocking operation.
|
||||
ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
|
||||
Status(context.Context) (*StatusInfo, error)
|
||||
}
|
||||
```
|
||||
|
||||
### Scanner Implementation (`scanner.go`)
|
||||
|
||||
The primary implementation that orchestrates the four-phase scanning pipeline. Each phase follows the Phase interface pattern:
|
||||
|
||||
```go
|
||||
type phase[T any] interface {
|
||||
producer() ppl.Producer[T]
|
||||
stages() []ppl.Stage[T]
|
||||
finalize(error) error
|
||||
description() string
|
||||
}
|
||||
```
|
||||
|
||||
This design enables:
|
||||
- Type-safe pipeline construction with generics
|
||||
- Modular phase implementation
|
||||
- Separation of concerns
|
||||
- Easy measurement of performance
|
||||
|
||||
### External Scanner (`external.go`)
|
||||
|
||||
The External Scanner is a specialized implementation that offloads the scanning process to a separate subprocess. This is specifically designed to address memory management challenges in long-running Navidrome instances.
|
||||
|
||||
```go
|
||||
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
|
||||
// memory leaks or retention in the main process, as the scanner can consume a lot of memory. The
|
||||
// external process will be spawned with the same executable as the current process, and will run
|
||||
// the "scan" command with the "--subprocess" flag.
|
||||
//
|
||||
// The external process will send progress updates to the main process through its STDOUT, and the main
|
||||
// process will forward them to the caller.
|
||||
```
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MP as Main Process
|
||||
participant ES as External Scanner
|
||||
participant SP as Subprocess (navidrome scan --subprocess)
|
||||
participant FS as File System
|
||||
participant DB as Database
|
||||
|
||||
Note over MP: DevExternalScanner=true
|
||||
MP->>ES: ScanAll(ctx, fullScan)
|
||||
activate ES
|
||||
|
||||
ES->>ES: Locate executable path
|
||||
ES->>SP: Start subprocess with args:<br>scan --subprocess --configfile ... etc.
|
||||
activate SP
|
||||
|
||||
Note over ES,SP: Create pipe for communication
|
||||
|
||||
par Subprocess executes scan
|
||||
SP->>FS: Read files & metadata
|
||||
SP->>DB: Update database
|
||||
and Main process monitors progress
|
||||
loop For each progress update
|
||||
SP->>ES: Send encoded progress info via stdout pipe
|
||||
ES->>MP: Forward progress info
|
||||
end
|
||||
end
|
||||
|
||||
SP-->>ES: Subprocess completes (success/error)
|
||||
deactivate SP
|
||||
ES-->>MP: Return aggregated warnings/errors
|
||||
deactivate ES
|
||||
```
|
||||
|
||||
Technical details:
|
||||
|
||||
1. **Process Isolation**
|
||||
- Spawns a separate process using the same executable
|
||||
- Uses the `--subprocess` flag to indicate it's running as a child process
|
||||
- Preserves configuration by passing required flags (`--configfile`, `--datafolder`, etc.)
|
||||
|
||||
2. **Inter-Process Communication**
|
||||
- Uses a pipe for bidirectional communication
|
||||
- Encodes/decodes progress updates using Go's `gob` encoding for efficient binary transfer
|
||||
- Properly handles process termination and error propagation
|
||||
|
||||
3. **Memory Management Benefits**
|
||||
- Scanning operations can be memory-intensive, especially with large music libraries
|
||||
- Memory leaks or excessive allocations are automatically cleaned up when the process terminates
|
||||
- Main Navidrome process remains stable even if scanner encounters memory-related issues
|
||||
|
||||
4. **Error Handling**
|
||||
- Detects non-zero exit codes from the subprocess
|
||||
- Propagates error messages back to the main process
|
||||
- Ensures resources are properly cleaned up, even in error conditions
|
||||
|
||||
## Scanning Process Flow
|
||||
|
||||
### Phase 1: Folder Scan (`phase_1_folders.go`)
|
||||
|
||||
This phase handles the initial traversal and media file processing.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 1] --> B{Full Scan?}
|
||||
B -- Yes --> C[Scan All Folders]
|
||||
B -- No --> D[Scan Modified Folders]
|
||||
C --> E[Read File Metadata]
|
||||
D --> E
|
||||
E --> F[Create Artists]
|
||||
E --> G[Create Albums]
|
||||
F --> H[Save to Database]
|
||||
G --> H
|
||||
H --> I[Mark Missing Folders]
|
||||
I --> J[End Phase 1]
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Folder Traversal**
|
||||
- Uses `walkDirTree` to traverse the directory structure
|
||||
- Handles symbolic links and hidden files
|
||||
- Processes `.ndignore` files for exclusions
|
||||
- Maps files to appropriate types (audio, image, playlist)
|
||||
|
||||
2. **Metadata Extraction**
|
||||
- Processes files in batches (defined by `filesBatchSize = 200`)
|
||||
- Extracts metadata using the configured storage backend
|
||||
- Converts raw metadata to `MediaFile` objects
|
||||
- Collects and normalizes tag information
|
||||
|
||||
3. **Album and Artist Creation**
|
||||
- Groups tracks by album ID
|
||||
- Creates album records from track metadata
|
||||
- Handles album ID changes by tracking previous IDs
|
||||
- Creates artist records from track participants
|
||||
|
||||
4. **Database Persistence**
|
||||
- Uses transactions for atomic updates
|
||||
- Preserves album annotations across ID changes
|
||||
- Updates library-artist mappings
|
||||
- Marks missing tracks for later processing
|
||||
- Pre-caches artwork for performance
|
||||
|
||||
### Phase 2: Missing Tracks Processing (`phase_2_missing_tracks.go`)
|
||||
|
||||
This phase identifies tracks that have moved or been deleted.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 2] --> B[Load Libraries]
|
||||
B --> C[Get Missing and Matching Tracks]
|
||||
C --> D[Group by PID]
|
||||
D --> E{Match Type?}
|
||||
E -- Exact --> F[Update Path]
|
||||
E -- Same PID --> G[Update If Only One]
|
||||
E -- Equivalent --> H[Update If No Better Match]
|
||||
F --> I[End Phase 2]
|
||||
G --> I
|
||||
H --> I
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Track Identification Strategy**
|
||||
- Uses persistent identifiers (PIDs) to track tracks across scans
|
||||
- Loads missing tracks and potential matches from the database
|
||||
- Groups tracks by PID to limit comparison scope
|
||||
|
||||
2. **Match Analysis**
|
||||
- Applies three levels of matching criteria:
|
||||
- Exact match (full metadata equivalence)
|
||||
- Single match for a PID
|
||||
- Equivalent match (same base path or similar metadata)
|
||||
- Prioritizes matches in order of confidence
|
||||
|
||||
3. **Database Update Strategy**
|
||||
- Preserves the original track ID
|
||||
- Updates the path to the new location
|
||||
- Deletes the duplicate entry
|
||||
- Uses transactions to ensure atomicity
|
||||
|
||||
### Phase 3: Album Refresh (`phase_3_refresh_albums.go`)
|
||||
|
||||
This phase updates album information based on the latest track metadata.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 3] --> B[Load Touched Albums]
|
||||
B --> C[Filter Unmodified]
|
||||
C --> D{Changes Detected?}
|
||||
D -- Yes --> E[Refresh Album Data]
|
||||
D -- No --> F[Skip]
|
||||
E --> G[Update Database]
|
||||
F --> H[End Phase 3]
|
||||
G --> H
|
||||
H --> I[Refresh Statistics]
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Album Selection Logic**
|
||||
- Loads albums that have been "touched" in previous phases
|
||||
- Uses a producer-consumer pattern for efficient processing
|
||||
- Retrieves all media files for each album for completeness
|
||||
|
||||
2. **Change Detection**
|
||||
- Rebuilds album metadata from associated tracks
|
||||
- Compares album attributes for changes
|
||||
- Skips albums with no media files
|
||||
- Avoids unnecessary database updates
|
||||
|
||||
3. **Statistics Refreshing**
|
||||
- Updates album play counts
|
||||
- Updates artist play counts
|
||||
- Maintains consistency between related entities
|
||||
|
||||
### Phase 4: Playlist Import (`phase_4_playlists.go`)
|
||||
|
||||
This phase imports and updates playlists from the file system.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Phase 4] --> B{AutoImportPlaylists?}
|
||||
B -- No --> C[Skip]
|
||||
B -- Yes --> D{Admin User Exists?}
|
||||
D -- No --> E[Log Warning & Skip]
|
||||
D -- Yes --> F[Load Folders with Playlists]
|
||||
F --> G{For Each Folder}
|
||||
G --> H[Read Directory]
|
||||
H --> I{For Each Playlist}
|
||||
I --> J[Import Playlist]
|
||||
J --> K[Pre-cache Artwork]
|
||||
K --> L[End Phase 4]
|
||||
C --> L
|
||||
E --> L
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Playlist Discovery**
|
||||
- Loads folders known to contain playlists
|
||||
- Focuses on folders that have been touched in previous phases
|
||||
- Handles both playlist formats (M3U, NSP)
|
||||
|
||||
2. **Import Process**
|
||||
- Uses the core.Playlists service for import
|
||||
- Handles both regular and smart playlists
|
||||
- Updates existing playlists when changed
|
||||
- Pre-caches playlist cover art
|
||||
|
||||
3. **Configuration Awareness**
|
||||
- Respects the AutoImportPlaylists setting
|
||||
- Requires an admin user for playlist import
|
||||
- Logs appropriate messages for configuration issues
|
||||
|
||||
## Final Processing Steps
|
||||
|
||||
After the four main phases, several finalization steps occur:
|
||||
|
||||
1. **Garbage Collection**
|
||||
- Removes dangling tracks with no files
|
||||
- Cleans up empty albums
|
||||
- Removes orphaned artists
|
||||
- Deletes orphaned annotations
|
||||
|
||||
2. **Statistics Refresh**
|
||||
- Updates artist song and album counts
|
||||
- Refreshes tag usage statistics
|
||||
- Updates aggregate metrics
|
||||
|
||||
3. **Library Status Update**
|
||||
- Marks scan as completed
|
||||
- Updates last scan timestamp
|
||||
- Stores persistent ID configuration
|
||||
|
||||
4. **Database Optimization**
|
||||
- Performs database maintenance
|
||||
- Optimizes tables and indexes
|
||||
- Reclaims space from deleted records
|
||||
|
||||
## File System Watching
|
||||
|
||||
The watcher system (`watcher.go`) provides real-time monitoring of file system changes:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Watcher] --> B[For Each Library]
|
||||
B --> C[Start Library Watcher]
|
||||
C --> D[Monitor File Events]
|
||||
D --> E{Change Detected?}
|
||||
E -- Yes --> F[Wait for More Changes]
|
||||
F --> G{Time Elapsed?}
|
||||
G -- Yes --> H[Trigger Scan]
|
||||
G -- No --> F
|
||||
H --> I[Wait for Scan Completion]
|
||||
I --> D
|
||||
```
|
||||
|
||||
**Technical implementation details:**
|
||||
|
||||
1. **Event Throttling**
|
||||
- Uses a timer to batch changes
|
||||
- Prevents excessive rescanning
|
||||
- Configurable wait period
|
||||
|
||||
2. **Library-specific Watching**
|
||||
- Each library has its own watcher goroutine
|
||||
- Translates paths to library-relative paths
|
||||
- Filters irrelevant changes
|
||||
|
||||
3. **Platform Adaptability**
|
||||
- Uses storage-provided watcher implementation
|
||||
- Supports different notification mechanisms per platform
|
||||
- Graceful fallback when watching is not supported
|
||||
|
||||
## Edge Cases and Optimizations
|
||||
|
||||
### Handling Album ID Changes
|
||||
|
||||
The scanner carefully manages album identity across scans:
|
||||
- Tracks previous album IDs to handle ID generation changes
|
||||
- Preserves annotations when IDs change
|
||||
- Maintains creation timestamps for consistent sorting
|
||||
|
||||
### Detecting Moved Files
|
||||
|
||||
A sophisticated algorithm identifies moved files:
|
||||
1. Groups missing and new files by their Persistent ID
|
||||
2. Applies multiple matching strategies in priority order
|
||||
3. Updates paths rather than creating duplicate entries
|
||||
|
||||
### Resuming Interrupted Scans
|
||||
|
||||
If a scan is interrupted:
|
||||
- The next scan detects this condition
|
||||
- Forces a full scan if the previous one was a full scan
|
||||
- Continues from where it left off for incremental scans
|
||||
|
||||
### Memory Efficiency
|
||||
|
||||
Several strategies minimize memory usage:
|
||||
- Batched file processing (200 files at a time)
|
||||
- External scanner process option
|
||||
- Database-side filtering where possible
|
||||
- Stream processing with pipelines
|
||||
|
||||
### Concurrency Control
|
||||
|
||||
The scanner implements a sophisticated concurrency model to optimize performance:
|
||||
|
||||
1. **Phase-Level Parallelism**:
|
||||
- Phases 1 and 2 run sequentially due to their dependencies
|
||||
- Phases 3 and 4 run in parallel using the `chain.RunParallel()` function
|
||||
- Final steps run sequentially to ensure data consistency
|
||||
|
||||
2. **Within-Phase Concurrency**:
|
||||
- Each phase has configurable concurrency for its stages
|
||||
- For example, `phase_1_folders.go` processes folders concurrently: `ppl.NewStage(p.processFolder, ppl.Name("process folder"), ppl.Concurrency(conf.Server.DevScannerThreads))`
|
||||
- Multiple stages can exist within a phase, each with its own concurrency level
|
||||
|
||||
3. **Pipeline Architecture Benefits**:
|
||||
- Producer-consumer pattern minimizes memory usage
|
||||
- Work is streamed through stages rather than accumulated
|
||||
- Back-pressure is automatically managed
|
||||
|
||||
4. **Thread Safety Mechanisms**:
|
||||
- Atomic counters for statistics gathering
|
||||
- Mutex protection for shared resources
|
||||
- Transactional database operations
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The scanner's behavior can be customized through several configuration settings that directly affect its operation:
|
||||
|
||||
### Core Scanner Options
|
||||
|
||||
| Setting | Description | Default |
|
||||
|-------------------------|------------------------------------------------------------------|----------------|
|
||||
| `Scanner.Enabled` | Whether the automatic scanner is enabled | true |
|
||||
| `Scanner.Schedule` | Cron expression or duration for scheduled scans (e.g., "@daily") | "0" (disabled) |
|
||||
| `Scanner.ScanOnStartup` | Whether to scan when the server starts | true |
|
||||
| `Scanner.WatcherWait` | Delay before triggering scan after file changes detected | 5s |
|
||||
| `Scanner.ArtistJoiner` | String used to join multiple artists in track metadata | " • " |
|
||||
|
||||
### Playlist Processing
|
||||
|
||||
| Setting | Description | Default |
|
||||
|-----------------------------|----------------------------------------------------------|---------|
|
||||
| `PlaylistsPath` | Path(s) to search for playlists (supports glob patterns) | "" |
|
||||
| `AutoImportPlaylists` | Whether to import playlists during scanning | true |
|
||||
|
||||
### Performance Options
|
||||
|
||||
| Setting | Description | Default |
|
||||
|----------------------|-----------------------------------------------------------|---------|
|
||||
| `DevExternalScanner` | Use external process for scanning (reduces memory issues) | true |
|
||||
| `DevScannerThreads` | Number of concurrent processing threads during scanning | 5 |
|
||||
|
||||
### Persistent ID Options
|
||||
|
||||
| Setting | Description | Default |
|
||||
|-------------|---------------------------------------------------------------------|---------------------------------------------------------------------|
|
||||
| `PID.Track` | Format for track persistent IDs (critical for tracking moved files) | "musicbrainz_trackid\|albumid,discnumber,tracknumber,title" |
|
||||
| `PID.Album` | Format for album persistent IDs (affects album grouping) | "musicbrainz_albumid\|albumartistid,album,albumversion,releasedate" |
|
||||
|
||||
These options can be set in the Navidrome configuration file (e.g., `navidrome.toml`) or via environment variables with the `ND_` prefix (e.g., `ND_SCANNER_ENABLED=false`). For environment variables, dots in option names are replaced with underscores.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Navidrome scanner represents a sophisticated system for efficiently managing music libraries. Its phase-based pipeline architecture, careful handling of edge cases, and performance optimizations allow it to handle libraries of significant size while maintaining data integrity and providing a responsive user experience.
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -266,6 +267,10 @@ 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 {
|
||||
|
||||
@@ -8,6 +8,8 @@ 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"
|
||||
@@ -17,8 +19,15 @@ import (
|
||||
|
||||
var _ = Describe("walk_dir_tree", func() {
|
||||
Describe("walkDirTree", func() {
|
||||
var fsys storage.MusicFS
|
||||
var (
|
||||
fsys storage.MusicFS
|
||||
job *scanJob
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = GinkgoT().Context()
|
||||
fsys = &mockMusicFS{
|
||||
FS: fstest.MapFS{
|
||||
"root/a/.ndignore": {Data: []byte("ignored/*")},
|
||||
@@ -32,21 +41,22 @@ 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")},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("walks all directories", func() {
|
||||
job := &scanJob{
|
||||
job = &scanJob{
|
||||
fs: fsys,
|
||||
lib: model.Library{Path: "/music"},
|
||||
}
|
||||
ctx := context.Background()
|
||||
})
|
||||
|
||||
// Helper function to call walkDirTree and collect folders from the results channel
|
||||
getFolders := func() map[string]*folderEntry {
|
||||
results, err := walkDirTree(ctx, job)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
folders := map[string]*folderEntry{}
|
||||
|
||||
g := errgroup.Group{}
|
||||
g.Go(func() error {
|
||||
for folder := range results {
|
||||
@@ -55,24 +65,42 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
return nil
|
||||
})
|
||||
_ = g.Wait()
|
||||
return folders
|
||||
}
|
||||
|
||||
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"))
|
||||
})
|
||||
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),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("helper functions", func() {
|
||||
@@ -81,74 +109,88 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
baseDir := filepath.Join("tests", "fixtures")
|
||||
|
||||
Describe("isDirOrSymlinkToDir", func() {
|
||||
It("returns true for normal dirs", func() {
|
||||
dirEntry := getDirEntry("tests", "fixtures")
|
||||
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeTrue())
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
It("returns true for symlinks to dirs", func() {
|
||||
dirEntry := getDirEntry(baseDir, "symlink2dir")
|
||||
Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(BeTrue())
|
||||
})
|
||||
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() {
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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": {},
|
||||
}}
|
||||
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),
|
||||
)
|
||||
})
|
||||
|
||||
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("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"))
|
||||
|
||||
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),
|
||||
)
|
||||
})
|
||||
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())
|
||||
|
||||
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": {},
|
||||
}}
|
||||
})
|
||||
|
||||
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{}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -205,11 +247,54 @@ 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) {
|
||||
return m.FS.Open(name)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ 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")
|
||||
|
||||
@@ -108,12 +108,12 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
|
||||
return addDefaultFilters(options)
|
||||
}
|
||||
|
||||
func SongWithLyrics(artist, title string) Options {
|
||||
func SongWithArtistTitle(artist, title string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "updated_at",
|
||||
Order: "desc",
|
||||
Max: 1,
|
||||
Filters: And{Eq{"artist": artist, "title": title}, NotEq{"lyrics": ""}},
|
||||
Filters: And{Eq{"artist": artist, "title": title}},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -95,9 +96,9 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||
artist, _ := p.String("artist")
|
||||
title, _ := p.String("title")
|
||||
response := newResponse()
|
||||
lyrics := responses.Lyrics{}
|
||||
response.Lyrics = &lyrics
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithLyrics(artist, title))
|
||||
lyricsResponse := responses.Lyrics{}
|
||||
response.Lyrics = &lyricsResponse
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithArtistTitle(artist, title))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -107,7 +108,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
structuredLyrics, err := mediaFiles[0].StructuredLyrics()
|
||||
structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -116,15 +117,15 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
lyrics.Artist = artist
|
||||
lyrics.Title = title
|
||||
lyricsResponse.Artist = artist
|
||||
lyricsResponse.Title = title
|
||||
|
||||
lyricsText := ""
|
||||
for _, line := range structuredLyrics[0].Line {
|
||||
lyricsText += line.Value + "\n"
|
||||
}
|
||||
|
||||
lyrics.Value = lyricsText
|
||||
lyricsResponse.Value = lyricsText
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -140,13 +141,13 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lyrics, err := mediaFile.StructuredLyrics()
|
||||
structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.LyricsList = buildLyricsList(mediaFile, lyrics)
|
||||
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ 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"
|
||||
@@ -32,6 +34,8 @@ 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() {
|
||||
@@ -109,6 +113,22 @@ 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() {
|
||||
|
||||
BIN
tests/fixtures/01 Invisible (RED) Edit Version.m4a
vendored
BIN
tests/fixtures/01 Invisible (RED) Edit Version.m4a
vendored
Binary file not shown.
BIN
tests/fixtures/test.aiff
vendored
BIN
tests/fixtures/test.aiff
vendored
Binary file not shown.
BIN
tests/fixtures/test.flac
vendored
BIN
tests/fixtures/test.flac
vendored
Binary file not shown.
6
tests/fixtures/test.lrc
vendored
Normal file
6
tests/fixtures/test.lrc
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[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
|
||||
BIN
tests/fixtures/test.m4a
vendored
BIN
tests/fixtures/test.m4a
vendored
Binary file not shown.
BIN
tests/fixtures/test.mp3
vendored
BIN
tests/fixtures/test.mp3
vendored
Binary file not shown.
BIN
tests/fixtures/test.ogg
vendored
BIN
tests/fixtures/test.ogg
vendored
Binary file not shown.
2
tests/fixtures/test.txt
vendored
Normal file
2
tests/fixtures/test.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
We're no strangers to love
|
||||
You know the rules and so do I
|
||||
BIN
tests/fixtures/test.wav
vendored
BIN
tests/fixtures/test.wav
vendored
Binary file not shown.
BIN
tests/fixtures/test.wma
vendored
BIN
tests/fixtures/test.wma
vendored
Binary file not shown.
BIN
tests/fixtures/test.wv
vendored
BIN
tests/fixtures/test.wv
vendored
Binary file not shown.
@@ -185,7 +185,6 @@ const AlbumSongs = (props) => {
|
||||
{...props}
|
||||
hasBulkActions={true}
|
||||
showDiscSubtitles={true}
|
||||
showReleaseDivider={true}
|
||||
contextAlwaysVisible={!isDesktop}
|
||||
classes={{ row: classes.row }}
|
||||
>
|
||||
|
||||
@@ -231,7 +231,6 @@ 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,
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ export const PlayButton = ({ record, size, className }) => {
|
||||
sort: { field: 'album', order: 'ASC' },
|
||||
filter: {
|
||||
album_id: record.id,
|
||||
release_date: record.releaseDate,
|
||||
disc_number: record.discNumber,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -59,59 +59,12 @@ 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 = (releaseDate, discNumber) => () => {
|
||||
onClick(releaseDate, discNumber)
|
||||
const handlePlaySubset = (discNumber) => () => {
|
||||
onClick(discNumber)
|
||||
}
|
||||
|
||||
let subtitle = []
|
||||
@@ -126,7 +79,7 @@ const DiscSubtitleRow = forwardRef(
|
||||
<TableRow
|
||||
hover
|
||||
ref={ref}
|
||||
onClick={handlePlaySubset(record.releaseDate, record.discNumber)}
|
||||
onClick={handlePlaySubset(record.discNumber)}
|
||||
className={classes.row}
|
||||
>
|
||||
<TableCell colSpan={colSpan}>
|
||||
@@ -139,7 +92,6 @@ const DiscSubtitleRow = forwardRef(
|
||||
<AlbumContextMenu
|
||||
record={{ id: record.albumId }}
|
||||
discNumber={record.discNumber}
|
||||
releaseDate={record.releaseDate}
|
||||
showLove={false}
|
||||
className={classes.contextMenu}
|
||||
hideShare={true}
|
||||
@@ -158,7 +110,6 @@ export const SongDatagridRow = ({
|
||||
record,
|
||||
children,
|
||||
firstTracksOfDiscs,
|
||||
firstTracksOfReleases,
|
||||
contextAlwaysVisible,
|
||||
onClickSubset,
|
||||
className,
|
||||
@@ -176,7 +127,6 @@ export const SongDatagridRow = ({
|
||||
discs: [
|
||||
{
|
||||
albumId: record?.albumId,
|
||||
releaseDate: record?.releaseDate,
|
||||
discNumber: record?.discNumber,
|
||||
},
|
||||
],
|
||||
@@ -209,15 +159,6 @@ 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}
|
||||
@@ -244,7 +185,6 @@ SongDatagridRow.propTypes = {
|
||||
record: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
firstTracksOfDiscs: PropTypes.instanceOf(Set),
|
||||
firstTracksOfReleases: PropTypes.instanceOf(Set),
|
||||
contextAlwaysVisible: PropTypes.bool,
|
||||
onClickSubset: PropTypes.func,
|
||||
}
|
||||
@@ -256,23 +196,16 @@ SongDatagridRow.defaultProps = {
|
||||
const SongDatagridBody = ({
|
||||
contextAlwaysVisible,
|
||||
showDiscSubtitles,
|
||||
showReleaseDivider,
|
||||
...rest
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const { ids, data } = rest
|
||||
|
||||
const playSubset = useCallback(
|
||||
(releaseDate, discNumber) => {
|
||||
(discNumber) => {
|
||||
let idsToPlay = []
|
||||
if (discNumber !== undefined) {
|
||||
idsToPlay = ids.filter(
|
||||
(id) =>
|
||||
data[id].releaseDate === releaseDate &&
|
||||
data[id].discNumber === discNumber,
|
||||
)
|
||||
} else {
|
||||
idsToPlay = ids.filter((id) => data[id].releaseDate === releaseDate)
|
||||
idsToPlay = ids.filter((id) => data[id].discNumber === discNumber)
|
||||
}
|
||||
dispatch(
|
||||
playTracks(
|
||||
@@ -297,8 +230,7 @@ const SongDatagridBody = ({
|
||||
foundSubtitle = foundSubtitle || data[id].discSubtitle
|
||||
if (
|
||||
acc.length === 0 ||
|
||||
(last && data[id].discNumber !== data[last].discNumber) ||
|
||||
(last && data[id].releaseDate !== data[last].releaseDate)
|
||||
(last && data[id].discNumber !== data[last].discNumber)
|
||||
) {
|
||||
acc.push(id)
|
||||
}
|
||||
@@ -311,37 +243,12 @@ 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}
|
||||
/>
|
||||
@@ -353,7 +260,6 @@ const SongDatagridBody = ({
|
||||
export const SongDatagrid = ({
|
||||
contextAlwaysVisible,
|
||||
showDiscSubtitles,
|
||||
showReleaseDivider,
|
||||
...rest
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
@@ -366,7 +272,6 @@ export const SongDatagrid = ({
|
||||
<SongDatagridBody
|
||||
contextAlwaysVisible={contextAlwaysVisible}
|
||||
showDiscSubtitles={showDiscSubtitles}
|
||||
showReleaseDivider={showReleaseDivider}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -376,6 +281,5 @@ export const SongDatagrid = ({
|
||||
SongDatagrid.propTypes = {
|
||||
contextAlwaysVisible: PropTypes.bool,
|
||||
showDiscSubtitles: PropTypes.bool,
|
||||
showReleaseDivider: PropTypes.bool,
|
||||
classes: PropTypes.object,
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ 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 />,
|
||||
@@ -92,7 +93,14 @@ export const SongInfo = (props) => {
|
||||
roles.push([name, record.participants[name].length])
|
||||
}
|
||||
|
||||
const optionalFields = ['discSubtitle', 'comment', 'bpm', 'genre', 'bitDepth']
|
||||
const optionalFields = [
|
||||
'discSubtitle',
|
||||
'comment',
|
||||
'bpm',
|
||||
'genre',
|
||||
'bitDepth',
|
||||
'sampleRate',
|
||||
]
|
||||
optionalFields.forEach((field) => {
|
||||
!record[field] && delete data[field]
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"updatedAt": "Updated at",
|
||||
"bitRate": "Bit rate",
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "Sample rate",
|
||||
"channels": "Channels",
|
||||
"discSubtitle": "Disc Subtitle",
|
||||
"starred": "Favourite",
|
||||
|
||||
Reference in New Issue
Block a user