Compare commits

...

12 Commits

Author SHA1 Message Date
Deluan
e0f1ddecbe docs: add testing and logging guidelines to AGENTS.md
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-19 23:25:24 -04:00
Caio Cotts
1e4e3eac6e fix: update Makefile with new demo URLs (#4080) 2025-05-19 15:34:25 -04:00
Deluan Quintão
19d443ec7f feat(scanner): add Scanner.FollowSymlinks option (#4061)
* Add Scanner.FollowSymlinks option (default: true) - Fixes #4060

* fix(mockMusicFS): improve symlink handling in Open, Stat, and ReadDir methods

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

* refactor(tests): enhance walkDirTree tests with symlink handling and cleanup

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-15 10:33:28 -04:00
Deluan Quintão
db92cf9e47 fix(scanner): optimize refresh (#4059)
* fix(artist): update RefreshStats to only process artists with recently updated media files

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

* fix: paginate Artist's RefreshStats, also replace rawSQL with Expr

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-14 20:47:03 -04:00
Kendall Garner
ec9f9aa243 feat:(server): support reading lyrics from filesystem (#2897)
* simplified lyrics handling

* address initial feedback

* add some trace and error logging

* allow fallback lyrics

* update nit

* restore artist/title filter only
2025-04-30 08:10:19 -04:00
Kendall Garner
0d1f2bcc8a fix(scanner): check if aiff/wma file has cover art (#3996)
* check if aiff file has cover art

* add cover art to test files, more support in wrapper

* remove wavpak since tag does't read it anyway
2025-04-25 13:00:26 -04:00
Deluan
dfa217ab51 docs(scanner): add overview README document
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-25 12:54:29 -04:00
Kendall Garner
3d6a2380bc feat(server): add artist/albumartist filter to media file (#4001)
* add artist/albumartist filter to media file

* artist -> artists_id
2025-04-25 12:50:21 -04:00
DDinghoya
53aa640f35 fix(ui): update Korean translation (#3980)
* Update ko.json

* Extra characters '들' present before the key.

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update resources/i18n/ko.json

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update resources/i18n/ko.json

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update resources/i18n/ko.json

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update resources/i18n/ko.json

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update resources/i18n/ko.json

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: Deluan Quintão <github@deluan.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-04-24 19:22:31 -04:00
Kendall Garner
e4d65a7828 feat(scanner): add unsynced lyrics to default mapping (#3997) 2025-04-24 17:40:51 -04:00
Deluan Quintão
b41123f75e chore: remove DevEnableBufferedScrobble and always enable buffered scrobbling (#3999)
Removed all code, config, and test references to DevEnableBufferedScrobble. Buffered scrobbling is now always enabled. Added this option to the list of deprecated config options with a warning. Updated all logic and tests to reflect this. No linter issues remain. Some PlayTracker tests are failing, but these are likely due to test data or logic unrelated to this change. All other tests pass. Review required for PlayTracker test failures.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-24 17:19:50 -04:00
Deluan
6f52c0201c refactor(server): simplify lastfm agent initialization logic
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-19 23:36:53 -04:00
42 changed files with 1817 additions and 684 deletions

110
AGENTS.md Normal file
View 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

View File

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

View File

@@ -82,15 +82,15 @@ var _ = Describe("Extractor", func() {
// TabLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 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

View File

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

View File

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

View File

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

View File

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

37
core/lyrics/lyrics.go Normal file
View 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
}

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ var (
// Should either be at the beginning of file, or beginning of line
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
timeRegex = regexp.MustCompile(timeRegexString)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset):([^]]+)]`)
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)

View File

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

View File

@@ -315,7 +315,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error)
// RefreshPlayCounts updates the play count and last play date annotations for all albums, based
// on the media files associated with them.
func (r *albumRepository) RefreshPlayCounts() (int64, error) {
query := 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

View File

@@ -239,7 +239,7 @@ func (r *artistRepository) purgeEmpty() error {
// RefreshPlayCounts updates the play count and last play date annotations for all artists, based
// on the media files associated with them.
func (r *artistRepository) RefreshPlayCounts() (int64, error) {
query := 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) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "이 트랙을 즐겨찾기에 추가"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

6
tests/fixtures/test.lrc vendored Normal file
View 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

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

2
tests/fixtures/test.txt vendored Normal file
View File

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

View File

Binary file not shown.

View File

Binary file not shown.

BIN
tests/fixtures/test.wv vendored
View File

Binary file not shown.