mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3910e77a7a | ||
|
|
196557a41a | ||
|
|
11d96f1da4 | ||
|
|
e628aafa4b | ||
|
|
ecf934feab | ||
|
|
5b89bf747f | ||
|
|
7a6845fa5a | ||
|
|
b6433057e9 | ||
|
|
d0784b6a21 | ||
|
|
b0e7941abe | ||
|
|
a02cfbe2a7 | ||
|
|
04603a1ea2 | ||
|
|
50870d3e61 | ||
|
|
27780683aa | ||
|
|
5baf0b80aa | ||
|
|
46be041e7b | ||
|
|
ee2e04b832 | ||
|
|
1ba390a72a | ||
|
|
8134edb5d1 |
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@@ -24,7 +24,6 @@ jobs:
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
problem-matchers: true
|
||||
args: --timeout 2m
|
||||
|
||||
@@ -99,6 +98,7 @@ jobs:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
retention-days: 7
|
||||
|
||||
i18n-lint:
|
||||
name: Lint i18n files
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@@ -24,5 +24,5 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
commit-message: Update translations
|
||||
title: Update translations from POEditor
|
||||
title: "fix(ui): update translations from POEditor"
|
||||
branch: update-translations
|
||||
|
||||
@@ -126,9 +126,47 @@ snapshot:
|
||||
|
||||
release:
|
||||
draft: true
|
||||
mode: append
|
||||
footer: |
|
||||
**Full Changelog**: https://github.com/navidrome/navidrome/compare/{{ .PreviousTag }}...{{ .Tag }}
|
||||
|
||||
## Helping out
|
||||
|
||||
This release is only possible thanks to the support of some **awesome people**!
|
||||
|
||||
Want to be one of them?
|
||||
You can [sponsor](https://github.com/sponsors/deluan), pay me a [Ko-fi](https://ko-fi.com/deluan) or [contribute with code](https://www.navidrome.org/docs/developers/).
|
||||
|
||||
## Where to go next?
|
||||
|
||||
* Read installation instructions on our [website](https://www.navidrome.org/docs/installation/).
|
||||
* Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)!
|
||||
|
||||
changelog:
|
||||
# sort: asc
|
||||
sort: asc
|
||||
use: github
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- Merge pull request
|
||||
- Merge remote-tracking branch
|
||||
- Merge branch
|
||||
- go mod tidy
|
||||
groups:
|
||||
- title: "New Features"
|
||||
regexp: '^.*?feat(\(.+\))??!?:.+$'
|
||||
order: 100
|
||||
- title: "Security updates"
|
||||
regexp: '^.*?sec(\(.+\))??!?:.+$'
|
||||
order: 150
|
||||
- title: "Bug fixes"
|
||||
regexp: '^.*?(fix|refactor)(\(.+\))??!?:.+$'
|
||||
order: 200
|
||||
- title: "Documentation updates"
|
||||
regexp: ^.*?docs?(\(.+\))??!?:.+$
|
||||
order: 400
|
||||
- title: "Build process updates"
|
||||
regexp: ^.*?(build|ci)(\(.+\))??!?:.+$
|
||||
order: 400
|
||||
- title: Other work
|
||||
order: 9999
|
||||
|
||||
@@ -48,14 +48,15 @@ This improves the readability of the messages
|
||||
It can be one of the following:
|
||||
1. **feat**: Addition of a new feature
|
||||
2. **fix**: Bug fix
|
||||
3. **docs**: Documentation Changes
|
||||
4. **style**: Changes to styling
|
||||
5. **refactor**: Refactoring of code
|
||||
6. **perf**: Code that affects performance
|
||||
7. **test**: Updating or improving the current tests
|
||||
8. **build**: Changes to Build process
|
||||
9. **revert**: Reverting to a previous commit
|
||||
10. **chore** : updating grunt tasks etc
|
||||
3. **sec**: Fixing security issues
|
||||
4. **docs**: Documentation Changes
|
||||
5. **style**: Changes to styling
|
||||
6. **refactor**: Refactoring of code
|
||||
7. **perf**: Code that affects performance
|
||||
8. **test**: Updating or improving the current tests
|
||||
9. **build**: Changes to Build process
|
||||
10. **revert**: Reverting to a previous commit
|
||||
11. **chore** : updating grunt tasks etc
|
||||
|
||||
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -20,6 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
@@ -133,34 +132,39 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) (*model.Playlist, error) {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Split(scanLines)
|
||||
var mfs model.MediaFiles
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "#PLAYLIST:") {
|
||||
if split := strings.Split(line, ":"); len(split) >= 2 {
|
||||
pls.Name = split[1]
|
||||
for lines := range slice.CollectChunks[string](400, slice.LinesFrom(reader)) {
|
||||
var filteredLines []string
|
||||
for _, line := range lines {
|
||||
line := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#PLAYLIST:") {
|
||||
if split := strings.Split(line, ":"); len(split) >= 2 {
|
||||
pls.Name = split[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
// Skip empty lines and extended info
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "file://") {
|
||||
line = strings.TrimPrefix(line, "file://")
|
||||
line, _ = url.QueryUnescape(line)
|
||||
}
|
||||
if baseDir != "" && !filepath.IsAbs(line) {
|
||||
line = filepath.Join(baseDir, line)
|
||||
}
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
// Skip empty lines and extended info
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "file://") {
|
||||
line = strings.TrimPrefix(line, "file://")
|
||||
line, _ = url.QueryUnescape(line)
|
||||
}
|
||||
if baseDir != "" && !filepath.IsAbs(line) {
|
||||
line = filepath.Join(baseDir, line)
|
||||
}
|
||||
mf, err := mediaFileRepository.FindByPath(line)
|
||||
found, err := mediaFileRepository.FindByPaths(filteredLines)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", line, err)
|
||||
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
||||
continue
|
||||
}
|
||||
mfs = append(mfs, *mf)
|
||||
if len(found) != len(filteredLines) {
|
||||
logMissingFiles(ctx, pls, filteredLines, found)
|
||||
}
|
||||
mfs = append(mfs, found...)
|
||||
}
|
||||
if pls.Name == "" {
|
||||
pls.Name = time.Now().Format(time.RFC3339)
|
||||
@@ -168,7 +172,20 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir s
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFiles(mfs)
|
||||
|
||||
return pls, scanner.Err()
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func logMissingFiles(ctx context.Context, pls *model.Playlist, lines []string, found model.MediaFiles) {
|
||||
missing := make(map[string]bool)
|
||||
for _, line := range lines {
|
||||
missing[line] = true
|
||||
}
|
||||
for _, mf := range found {
|
||||
delete(missing, mf.Path)
|
||||
}
|
||||
for path := range missing {
|
||||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
@@ -199,30 +216,6 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
|
||||
// From https://stackoverflow.com/a/41433698
|
||||
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
|
||||
if data[i] == '\n' {
|
||||
// We have a line terminated by single newline.
|
||||
return i + 1, data[0:i], nil
|
||||
}
|
||||
advance = i + 1
|
||||
if len(data) > i+1 && data[i+1] == '\n' {
|
||||
advance += 1
|
||||
}
|
||||
return advance, data[0:i], nil
|
||||
}
|
||||
// If we're at EOF, we have a final, non-terminated line. Return it.
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
// Request more data.
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||
name *string, comment *string, public *bool,
|
||||
idsToAdd []string, idxToRemove []int) error {
|
||||
|
||||
@@ -118,6 +118,15 @@ func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *mockedMediaFile) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
var mfs model.MediaFiles
|
||||
for _, path := range paths {
|
||||
mf, _ := r.FindByPath(path)
|
||||
mfs = append(mfs, *mf)
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
type mockedPlaylist struct {
|
||||
last *model.Playlist
|
||||
model.PlaylistRepository
|
||||
|
||||
2
go.mod
2
go.mod
@@ -2,6 +2,8 @@ module github.com/navidrome/navidrome
|
||||
|
||||
go 1.23
|
||||
|
||||
toolchain go1.23.1
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
||||
|
||||
@@ -265,6 +265,7 @@ type MediaFileRepository interface {
|
||||
// Queries by path to support the scanner, no Annotations or Bookmarks required in the response
|
||||
FindAllByPath(path string) (MediaFiles, error)
|
||||
FindByPath(path string) (*MediaFile, error)
|
||||
FindByPaths(paths []string) (MediaFiles, error)
|
||||
FindPathsRecursively(basePath string) ([]string, error)
|
||||
DeleteByPath(path string) (int64, error)
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
r := &albumRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "album"
|
||||
r.registerModel(&model.Album{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
@@ -66,6 +67,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
"recently_played": recentlyPlayedFilter,
|
||||
"starred": booleanFilter,
|
||||
"has_rating": hasRatingFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
@@ -73,8 +75,9 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
"artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"album_artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
@@ -82,8 +85,9 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"album_artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, order_album_name asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
@@ -59,18 +60,22 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
|
||||
r.tableName = "artist" // To be used by the idFilter below
|
||||
r.registerModel(&model.Artist{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name)",
|
||||
"name": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name)",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "order_artist_name",
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
}
|
||||
return r
|
||||
@@ -139,7 +144,11 @@ func (r *artistRepository) toModels(dba []dbArtist) model.Artists {
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
name := strings.ToLower(str.RemoveArticle(a.Name))
|
||||
source := a.Name
|
||||
if conf.Server.PreferSortTags {
|
||||
source = cmp.Or(a.SortArtistName, a.OrderArtistName, source)
|
||||
}
|
||||
name := strings.ToLower(str.RemoveArticle(source))
|
||||
for k, v := range r.indexGroups {
|
||||
key := strings.ToLower(k)
|
||||
if strings.HasPrefix(name, key) {
|
||||
@@ -151,7 +160,11 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
all, err := r.GetAll(model.QueryOptions{Sort: "order_artist_name"})
|
||||
sortColumn := "order_artist_name"
|
||||
if conf.Server.PreferSortTags {
|
||||
sortColumn = "sort_artist_name, order_artist_name"
|
||||
}
|
||||
all, err := r.GetAll(model.QueryOptions{Sort: sortColumn})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/fatih/structs"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
. "github.com/onsi/gomega/gstruct"
|
||||
@@ -43,8 +45,148 @@ var _ = Describe("ArtistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetIndexKey", func() {
|
||||
r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)}
|
||||
It("returns the index key when PreferSortTags is true and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a) // defines export_test.go
|
||||
Expect(idx).To(Equal("F"))
|
||||
|
||||
a = model.Artist{SortArtistName: "foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("F"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, SortArtistName is empty and OrderArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
|
||||
a = model.Artist{SortArtistName: "", OrderArtistName: "bar", Name: "Qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, both SortArtistName, OrderArtistName are empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
|
||||
a = model.Artist{SortArtistName: "", OrderArtistName: "", Name: "qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is false and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, SortArtistName is empty and OrderArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, both sort_artist_name, order_artist_name are empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
|
||||
a = model.Artist{SortArtistName: "", OrderArtistName: "", Name: "qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetIndex", func() {
|
||||
It("returns the index", func() {
|
||||
It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "F",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("returns the index when PreferSortTags is false and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the index when PreferSortTags is false and SortArtistName is empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
|
||||
5
persistence/export_test.go
Normal file
5
persistence/export_test.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package persistence
|
||||
|
||||
// Definitions for testing private methods
|
||||
|
||||
var GetIndexKey = (*artistRepository).getIndexKey
|
||||
@@ -24,28 +24,30 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) *mediaFileRepos
|
||||
r := &mediaFileRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "media_file"
|
||||
r.registerModel(&model.MediaFile{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"title": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"id": idFilter(r.tableName),
|
||||
"title": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"title": "COALESCE(NULLIF(sort_title,''),title)",
|
||||
"artist": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc, COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_title,''),title) asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"created_at": "media_file.created_at",
|
||||
"track_number": "album, release_date, disc_number, track_number",
|
||||
"title": "COALESCE(NULLIF(sort_title,''),order_title)",
|
||||
"artist": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc, COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_title,''),title) asc",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"created_at": "media_file.created_at",
|
||||
"track_number": "album, release_date, disc_number, track_number",
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
}
|
||||
return r
|
||||
@@ -125,6 +127,15 @@ func (r *mediaFileRepository) FindByPath(path string) (*model.MediaFile, error)
|
||||
return &res[0], nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
|
||||
var res model.MediaFiles
|
||||
if err := r.queryAll(sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func cleanPath(path string) string {
|
||||
path = filepath.Clean(path)
|
||||
if !strings.HasSuffix(path, string(os.PathSeparator)) {
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestPersistence(t *testing.T) {
|
||||
//conf.Server.DbPath = "./test-123.db"
|
||||
conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
|
||||
defer db.Init()()
|
||||
log.SetLevel(log.LevelError)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Persistence Suite")
|
||||
}
|
||||
@@ -35,8 +35,8 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"}
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles", AlbumCount: 2, FullText: " beatles the"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -16,10 +15,6 @@ var _ = Describe("SQLStore", func() {
|
||||
BeforeEach(func() {
|
||||
ds = New(db.Db())
|
||||
ctx = context.Background()
|
||||
log.SetLevel(log.LevelFatal)
|
||||
})
|
||||
AfterEach(func() {
|
||||
log.SetLevel(log.LevelError)
|
||||
})
|
||||
Describe("WithTx", func() {
|
||||
Context("When block returns nil", func() {
|
||||
|
||||
@@ -21,6 +21,9 @@ func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerReposi
|
||||
r.registerModel(&model.Player{}, map[string]filterFunc{
|
||||
"name": containsFilter("player.name"),
|
||||
})
|
||||
r.sortMappings = map[string]string{
|
||||
"user_name": "username", //TODO rename all user_name and userName to username
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
||||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
})
|
||||
r.sortMappings = map[string]string{
|
||||
"owner_name": "owner_name",
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,11 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
p.tableName = "playlist_tracks"
|
||||
p.registerModel(&model.PlaylistTrack{}, nil)
|
||||
p.sortMappings = map[string]string{
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name asc",
|
||||
"album": "order_album_name asc, order_album_artist_name asc",
|
||||
"title": "order_title",
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name asc",
|
||||
"album": "order_album_name asc, order_album_artist_name asc",
|
||||
"title": "order_title",
|
||||
"duration": "duration", // To make sure the field will be whitelisted
|
||||
}
|
||||
if conf.Server.PreferSortTags {
|
||||
p.sortMappings["artist"] = "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc"
|
||||
|
||||
@@ -112,8 +112,8 @@ func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFil
|
||||
ids[i] = t.ID
|
||||
}
|
||||
|
||||
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
chunks := slice.BreakUp(ids, 50)
|
||||
// Break the list in chunks, up to 500 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
chunks := slice.BreakUp(ids, 500)
|
||||
|
||||
// Query each chunk of media_file ids and store results in a map
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.db)
|
||||
@@ -131,9 +131,12 @@ func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFil
|
||||
}
|
||||
|
||||
// Create a new list of tracks with the same order as the original
|
||||
newTracks := make(model.MediaFiles, len(tracks))
|
||||
for i, t := range tracks {
|
||||
newTracks[i] = trackMap[t.ID]
|
||||
// Exclude tracks that are not in the DB anymore
|
||||
newTracks := make(model.MediaFiles, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
if track, ok := trackMap[t.ID]; ok {
|
||||
newTracks = append(newTracks, track)
|
||||
}
|
||||
}
|
||||
return newTracks
|
||||
}
|
||||
|
||||
@@ -16,9 +16,10 @@ import (
|
||||
|
||||
var _ = Describe("PlayQueueRepository", func() {
|
||||
var repo model.PlayQueueRepository
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPlayQueueRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
})
|
||||
@@ -51,6 +52,30 @@ var _ = Describe("PlayQueueRepository", func() {
|
||||
AssertPlayQueue(another, actual)
|
||||
Expect(countPlayQueues(repo, "userid")).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not return tracks if they don't exist in the DB", func() {
|
||||
// Add a new song to the DB
|
||||
newSong := songRadioactivity
|
||||
newSong.ID = "temp-track"
|
||||
mfRepo := NewMediaFileRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
|
||||
Expect(mfRepo.Put(&newSong)).To(Succeed())
|
||||
|
||||
// Create a playqueue with the new song
|
||||
pq := aPlayQueue("userid", newSong.ID, 0, newSong, songAntenna)
|
||||
Expect(repo.Store(pq)).To(Succeed())
|
||||
|
||||
// Delete the new song
|
||||
Expect(mfRepo.Delete("temp-track")).To(Succeed())
|
||||
|
||||
// Retrieve the playqueue
|
||||
actual, err := repo.Retrieve("userid")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The playqueue should not contain the deleted track
|
||||
Expect(actual.Items).To(HaveLen(1))
|
||||
Expect(actual.Items[0].ID).To(Equal(songAntenna.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.Share{}, map[string]filterFunc{})
|
||||
r.sortMappings = map[string]string{
|
||||
"username": "username",
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -167,20 +167,15 @@ func (r sqlRepository) seedKey() string {
|
||||
return r.tableName + userId(r.ctx)
|
||||
}
|
||||
|
||||
func (r sqlRepository) seededRandomSort() string {
|
||||
return fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName)
|
||||
}
|
||||
|
||||
func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) {
|
||||
if len(options) == 0 || options[0].Sort != "random" {
|
||||
return
|
||||
}
|
||||
|
||||
options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName)
|
||||
if options[0].Seed != "" {
|
||||
hasher.SetSeed(r.seedKey(), options[0].Seed)
|
||||
return
|
||||
}
|
||||
|
||||
if options[0].Offset == 0 {
|
||||
hasher.Reseed(r.seedKey())
|
||||
}
|
||||
|
||||
@@ -6,16 +6,25 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/hasher"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sqlRepository", func() {
|
||||
r := sqlRepository{}
|
||||
var r sqlRepository
|
||||
BeforeEach(func() {
|
||||
r.ctx = request.WithUser(context.Background(), model.User{ID: "user-id"})
|
||||
r.tableName = "table"
|
||||
})
|
||||
|
||||
Describe("applyOptions", func() {
|
||||
var sq squirrel.SelectBuilder
|
||||
BeforeEach(func() {
|
||||
sq = squirrel.Select("*").From("test")
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "title",
|
||||
}
|
||||
})
|
||||
It("does not add any clauses when options is empty", func() {
|
||||
sq = r.applyOptions(sq, model.QueryOptions{})
|
||||
@@ -30,17 +39,11 @@ var _ = Describe("sqlRepository", func() {
|
||||
Offset: 2,
|
||||
})
|
||||
sql, _, _ := sq.ToSql()
|
||||
Expect(sql).To(Equal("SELECT * FROM test ORDER BY name desc LIMIT 1 OFFSET 2"))
|
||||
Expect(sql).To(Equal("SELECT * FROM test ORDER BY title desc LIMIT 1 OFFSET 2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("toSQL", func() {
|
||||
var r sqlRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
r = sqlRepository{}
|
||||
})
|
||||
|
||||
It("returns error for invalid SQL", func() {
|
||||
sq := squirrel.Select("*").From("test").Where(1)
|
||||
_, _, err := r.toSQL(sq)
|
||||
@@ -175,13 +178,37 @@ var _ = Describe("sqlRepository", func() {
|
||||
Expect(sql).To(Equal("name asc, coalesce(nullif(release_date, ''), nullif(original_date, '')) desc, status desc"))
|
||||
})
|
||||
})
|
||||
Context("seededRandomSort", func() {
|
||||
It("returns a random sort order function call", func() {
|
||||
r.ctx = request.WithUser(context.Background(), model.User{ID: "2"})
|
||||
r.tableName = "media_file"
|
||||
sql := r.seededRandomSort()
|
||||
Expect(sql).To(ContainSubstring("SEEDEDRAND('media_file2', media_file.id)"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resetSeededRandom", func() {
|
||||
var id string
|
||||
BeforeEach(func() {
|
||||
id = r.seedKey()
|
||||
hasher.SetSeed(id, "")
|
||||
})
|
||||
It("does not reset seed if sort is not random", func() {
|
||||
var options []model.QueryOptions
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(BeEmpty())
|
||||
})
|
||||
It("resets seed if sort is random", func() {
|
||||
options := []model.QueryOptions{{Sort: "random"}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).NotTo(BeEmpty())
|
||||
})
|
||||
It("resets seed if sort is random and seed is provided", func() {
|
||||
options := []model.QueryOptions{{Sort: "random", Seed: "seed"}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
|
||||
})
|
||||
It("keeps seed when paginating", func() {
|
||||
options := []model.QueryOptions{{Sort: "random", Seed: "seed", Offset: 0}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
|
||||
|
||||
options = []model.QueryOptions{{Sort: "random", Offset: 1}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -440,8 +440,8 @@
|
||||
"totalScanned": "Insgesamt gescannte Ordner",
|
||||
"quickScan": "Schneller Scan",
|
||||
"fullScan": "Kompletter Scan",
|
||||
"serverUptime": "",
|
||||
"serverDown": ""
|
||||
"serverUptime": "Server-Betriebszeit",
|
||||
"serverDown": "OFFLINE"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Hotkeys",
|
||||
|
||||
@@ -2,139 +2,139 @@
|
||||
"languageName": "한국어",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "곡",
|
||||
"name": "노래 |||| 노래들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"duration": "길이",
|
||||
"duration": "시간",
|
||||
"trackNumber": "#",
|
||||
"playCount": "재생 수",
|
||||
"playCount": "재생 횟수",
|
||||
"title": "제목",
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"genre": "장르",
|
||||
"compilation": "Compilation",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"size": "파일 크기",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"updatedAt": "업데이트됨",
|
||||
"bitRate": "비트레이트",
|
||||
"discSubtitle": "디스크 서브타이틀",
|
||||
"starred": "좋아요",
|
||||
"comment": "코멘트",
|
||||
"starred": "즐겨찾기",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"quality": "품질",
|
||||
"bpm": "BPM",
|
||||
"playDate": "마지막 재생",
|
||||
"channels": "채널",
|
||||
"createdAt": "추가 날짜"
|
||||
"createdAt": "추가된 날짜"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "마지막에 재생",
|
||||
"playNow": "바로 재생",
|
||||
"addToPlaylist": "플레이리스트에 추가",
|
||||
"shuffleAll": "모든 곡 셔플",
|
||||
"addToQueue": "나중에 재생",
|
||||
"playNow": "지금 재생",
|
||||
"addToPlaylist": "재생목록에 추가",
|
||||
"shuffleAll": "모든 노래 셔플",
|
||||
"download": "다운로드",
|
||||
"playNext": "다음에 재생",
|
||||
"info": "상세 정보"
|
||||
"playNext": "다음 재생",
|
||||
"info": "정보"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "앨범",
|
||||
"name": "앨범 |||| 앨범들",
|
||||
"fields": {
|
||||
"albumArtist": "앨범 아티스트",
|
||||
"artist": "아티스트",
|
||||
"duration": "길이",
|
||||
"songCount": "곡",
|
||||
"playCount": "재생 수",
|
||||
"duration": "시간",
|
||||
"songCount": "노래",
|
||||
"playCount": "재생 횟수",
|
||||
"name": "이름",
|
||||
"genre": "장르",
|
||||
"compilation": "Compilation",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"comment": "코멘트",
|
||||
"updatedAt": "업데이트됨",
|
||||
"comment": "댓글",
|
||||
"rating": "평가",
|
||||
"createdAt": "추가 날짜",
|
||||
"createdAt": "추가된 날짜",
|
||||
"size": "크기",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": ""
|
||||
"originalDate": "오리지널",
|
||||
"releaseDate": "발매일",
|
||||
"releases": "발매 음반 |||| 발매 음반들",
|
||||
"released": "발매됨"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "재생",
|
||||
"playNext": "다음에 재생",
|
||||
"addToQueue": "마지막에 재생",
|
||||
"addToQueue": "나중에 재생",
|
||||
"shuffle": "셔플",
|
||||
"addToPlaylist": "플레이리스트에 추가",
|
||||
"addToPlaylist": "재생목록 추가",
|
||||
"download": "다운로드",
|
||||
"info": "상세 정보",
|
||||
"info": "정보",
|
||||
"share": "공유"
|
||||
},
|
||||
"lists": {
|
||||
"all": "전체",
|
||||
"all": "모두",
|
||||
"random": "랜덤",
|
||||
"recentlyAdded": "최근 추가",
|
||||
"recentlyPlayed": "최근 재생",
|
||||
"mostPlayed": "가장 많이 재생",
|
||||
"starred": "좋아요",
|
||||
"recentlyAdded": "최근 추가됨",
|
||||
"recentlyPlayed": "최근 재생됨",
|
||||
"mostPlayed": "가장 많이 재생됨",
|
||||
"starred": "즐겨찾기",
|
||||
"topRated": "높은 평가"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "아티스트",
|
||||
"name": "아티스트 |||| 아티스트들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"albumCount": "앨범 수",
|
||||
"songCount": "곡 수",
|
||||
"playCount": "재생 수",
|
||||
"songCount": "노래 수",
|
||||
"playCount": "재생 횟수",
|
||||
"rating": "평가",
|
||||
"genre": "장르",
|
||||
"size": "크기"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "사용자",
|
||||
"name": "사용자 |||| 사용자들",
|
||||
"fields": {
|
||||
"userName": "사용자명",
|
||||
"userName": "사용자이름",
|
||||
"isAdmin": "관리자",
|
||||
"lastLoginAt": "최종 로그인",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"lastLoginAt": "마지막 로그인",
|
||||
"updatedAt": "업데이트됨",
|
||||
"name": "이름",
|
||||
"password": "비밀번호",
|
||||
"createdAt": "생성 날짜",
|
||||
"changePassword": "비밀번호를 변경하시겠습니까?",
|
||||
"createdAt": "생성됨",
|
||||
"changePassword": "비밀번호를 변경할까요?",
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"newPassword": "새로운 비밀번호",
|
||||
"newPassword": "새 비밀번호",
|
||||
"token": "토큰"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "이름 변경은 다음 로그인 이후에 반영됩니다"
|
||||
"name": "이름 변경 사항은 다음 로그인 이후에 반영됨"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "사용자가 생성되었습니다",
|
||||
"updated": "사용자가 업데이트되었습니다",
|
||||
"deleted": "사용자가 삭제되었습니다"
|
||||
"created": "사용자 생성됨",
|
||||
"updated": "사용자 업데이트됨",
|
||||
"deleted": "사용자 삭제됨"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요",
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "플레이어",
|
||||
"name": "플레이어 |||| 플레이어들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"transcodingId": "트랜스코딩",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"client": "클라이언트",
|
||||
"userName": "사용자명",
|
||||
"lastSeen": "마지막 사용",
|
||||
"reportRealPath": "실제 파일 경로 반환",
|
||||
"scrobbleEnabled": "다른 서비스에 scrobble"
|
||||
"userName": "사용자이름",
|
||||
"lastSeen": "마지막으로 봤음",
|
||||
"reportRealPath": "실제 경로 보고서",
|
||||
"scrobbleEnabled": "외부 서비스에 스크로블 보내기"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "트랜스코딩",
|
||||
"name": "트랜스코딩 |||| 트랜스코딩들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"targetFormat": "대상 포맷",
|
||||
@@ -143,111 +143,111 @@
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "플레이리스트",
|
||||
"name": "재생목록 |||| 재생목록들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"duration": "시간",
|
||||
"duration": "지속",
|
||||
"ownerName": "소유자",
|
||||
"public": "공개",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"createdAt": "생성 날짜",
|
||||
"songCount": "곡",
|
||||
"comment": "코멘트",
|
||||
"sync": "자동 임포트",
|
||||
"path": "임포트 원본"
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"songCount": "노래",
|
||||
"comment": "댓글",
|
||||
"sync": "자동 가져오기",
|
||||
"path": "다음에서 가져오기"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "플레이리스트 선택",
|
||||
"addNewPlaylist": "'%{name}' 생성",
|
||||
"selectPlaylist": "재생목록 선택:",
|
||||
"addNewPlaylist": "\"%{name}\" 만들기",
|
||||
"export": "내보내기",
|
||||
"makePublic": "공개하기",
|
||||
"makePrivate": "비공개로 전환하기"
|
||||
"makePublic": "공개",
|
||||
"makePrivate": "비공개"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "중복된 곡 추가",
|
||||
"song_exist": "이미 플레이리스트에 존재하는 곡입니다. 추가하시겠습니까?"
|
||||
"duplicate_song": "중복된 노래 추가",
|
||||
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "라디오",
|
||||
"name": "라디오 |||| 라디오들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"streamUrl": "스트리밍 URL",
|
||||
"homePageUrl": "홈페이지 URL",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"createdAt": "생성 날짜"
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "바로 재생"
|
||||
"playNow": "지금 재생"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "공유",
|
||||
"name": "공유 |||| 공유되는 것들",
|
||||
"fields": {
|
||||
"username": "공유자",
|
||||
"username": "공유됨",
|
||||
"url": "URL",
|
||||
"description": "설명",
|
||||
"contents": "컨텐츠",
|
||||
"expiresAt": "만료 날짜",
|
||||
"lastVisitedAt": "최근 방문",
|
||||
"expiresAt": "만료",
|
||||
"lastVisitedAt": "마지막 방문",
|
||||
"visitCount": "방문 수",
|
||||
"format": "포맷",
|
||||
"maxBitRate": "최대 비트레이트",
|
||||
"updatedAt": "업데이트 날짜",
|
||||
"createdAt": "생성 날짜",
|
||||
"downloadable": ""
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "생성됨",
|
||||
"downloadable": "다운로드를 허용할까요?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Navidrome을 설치해 주셔서 감사합니다!",
|
||||
"welcome2": "관리자 사용자를 생성하고 시작해 보세요",
|
||||
"welcome2": "관리자를 만들고 시작해 보세요",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"buttonCreateAdmin": "관리자 생성",
|
||||
"auth_check_error": "인증에 실패했습니다. 다시 로그인하세요",
|
||||
"user_menu": "프로필",
|
||||
"username": "사용자명",
|
||||
"buttonCreateAdmin": "관리자 만들기",
|
||||
"auth_check_error": "계속하려면 로그인하세요",
|
||||
"user_menu": "프로파일",
|
||||
"username": "사용자이름",
|
||||
"password": "비밀번호",
|
||||
"sign_in": "로그인",
|
||||
"sign_in_error": "인증에 실패했습니다. 입력값을 확인하세요",
|
||||
"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을 입력하세요"
|
||||
"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": "%{smart_count}개 선택",
|
||||
"back": "뒤로 가기",
|
||||
"bulk_actions": "1 개 항목이 선택되었음 |||| %{smart_count} 개 항목이 선택되었음",
|
||||
"cancel": "취소",
|
||||
"clear_input_value": "비우기",
|
||||
"clear_input_value": "값 지우기",
|
||||
"clone": "복제",
|
||||
"confirm": "확인",
|
||||
"create": "생성",
|
||||
"create": "만들기",
|
||||
"delete": "삭제",
|
||||
"edit": "편집",
|
||||
"export": "내보내기",
|
||||
"list": "목록",
|
||||
"refresh": "새로고침",
|
||||
"remove_filter": "필터 삭제",
|
||||
"remove": "삭제",
|
||||
"refresh": "새로 고침",
|
||||
"remove_filter": "이 필터 제거",
|
||||
"remove": "제거",
|
||||
"save": "저장",
|
||||
"search": "검색",
|
||||
"show": "상세 정보",
|
||||
"show": "표시",
|
||||
"sort": "정렬",
|
||||
"undo": "실행 취소",
|
||||
"expand": "확장",
|
||||
@@ -255,7 +255,7 @@
|
||||
"open_menu": "메뉴 열기",
|
||||
"close_menu": "메뉴 닫기",
|
||||
"unselect": "선택 해제",
|
||||
"skip": "스킵",
|
||||
"skip": "건너뛰기",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "공유",
|
||||
"download": "다운로드"
|
||||
@@ -265,115 +265,115 @@
|
||||
"false": "아니요"
|
||||
},
|
||||
"page": {
|
||||
"create": "%{name} 생성",
|
||||
"create": "%{name} 만들기",
|
||||
"dashboard": "대시보드",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "문제가 발생했습니다",
|
||||
"error": "문제가 발생하였음",
|
||||
"list": "%{name}",
|
||||
"loading": "로딩 중입니다. 잠시 기다려주세요",
|
||||
"not_found": "찾을 수 없습니다",
|
||||
"loading": "로딩 중",
|
||||
"not_found": "찾을 수 없음",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "%{name}이(가) 없습니다",
|
||||
"invite": "생성하시겠습니까?"
|
||||
"empty": "아직 %{name}이(가) 없습니다.",
|
||||
"invite": "추가할까요?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "파일을 끌어 놓거나 클릭하여 업로드하세요",
|
||||
"upload_single": "파일을 끌어 놓거나 클릭하여 업로드하세요"
|
||||
"upload_several": "업로드할 파일을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 파일을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "이미지를 끌어 놓거나 클릭하여 업로드하세요",
|
||||
"upload_single": "이미지를 끌어 놓거나 클릭하여 업로드하세요"
|
||||
"upload_several": "업로드할 사진을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
|
||||
"upload_single": "업로드할 사진을 몇 개 놓거나 클릭하여 선택하세요."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "사용 가능한 데이터가 없습니다",
|
||||
"many_missing": "선택한 데이터 중 일부가 사용 가능하지 않습니다",
|
||||
"single_missing": "선택한 데이터가 사용 가능하지 않습니다"
|
||||
"all_missing": "참조 데이터를 찾을 수 없습니다.",
|
||||
"many_missing": "연관된 참조 중 적어도 하나는 더 이상 사용할 수 없는 것 같습니다.",
|
||||
"single_missing": "연관된 참조는 더 이상 사용할 수 없는 것 같습니다."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "숨기기",
|
||||
"toggle_hidden": "보이기"
|
||||
"toggle_visible": "비밀번호 숨기기",
|
||||
"toggle_hidden": "비밀번호 표시"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "정보",
|
||||
"are_you_sure": "정말로 이 작업을 수행하시겠습니까?",
|
||||
"bulk_delete_content": "%{name}을(를) 삭제하시겠습니까? |||| %{smart_count}개의 항목을 삭제하시겠습니까?",
|
||||
"bulk_delete_title": "%{name} 삭제 |||| %{name} %{smart_count}개 삭제",
|
||||
"delete_content": "삭제하시겠습니까?",
|
||||
"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": "로딩 중입니다. 잠시만 기다려주세요",
|
||||
"details": "상세 정보",
|
||||
"error": "클라이언트 오류가 발생하여 요청을 완료할 수 없습니다.",
|
||||
"invalid_form": "양식이 유효하지 않습니다. 오류를 확인하세요",
|
||||
"loading": "페이지가 로드 중입니다. 잠시만 기다려 주세요",
|
||||
"no": "아니요",
|
||||
"not_found": "잘못된 URL을 입력하거나 잘못된 링크를 따라갔습니다",
|
||||
"not_found": "잘못된 URL을 입력했거나 잘못된 링크를 클릭했습니다.",
|
||||
"yes": "예",
|
||||
"unsaved_changes": "변경 사항이 저장되지 않았습니다. 이 페이지를 떠나시겠습니까?"
|
||||
"unsaved_changes": "일부 변경 사항이 저장되지 않았습니다. 무시할까요?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "결과가 없습니다",
|
||||
"no_more_results": "페이지 %{page}는 최대 페이지 수를 초과했습니다. 이전 페이지로 돌아가세요",
|
||||
"page_out_of_boundaries": "페이지 %{page}는 최대 페이지 수를 초과했습니다",
|
||||
"page_out_from_end": "마지막 페이지 이후로 이동할 수 없습니다",
|
||||
"page_out_from_begin": "첫 페이지 이전으로 이동할 수 없습니다",
|
||||
"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": "페이지당 항목 수:",
|
||||
"page_rows_per_page": "페이지당 항목:",
|
||||
"next": "다음",
|
||||
"prev": "이전",
|
||||
"skip_nav": "메뉴 건너뛰기"
|
||||
"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": "새로운 버전이 사용 가능합니다! 페이지를 새로 고쳐주세요."
|
||||
"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": "표시 열",
|
||||
"columnsToDisplay": "표시할 열",
|
||||
"layout": "레이아웃",
|
||||
"grid": "그리드",
|
||||
"table": "테이블"
|
||||
"grid": "격자",
|
||||
"table": "표"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "주의",
|
||||
"transcodingDisabled": "보안상의 이유로 웹 인터페이스에서 트랜스코드 설정이 비활성화되어 있습니다.\n이를 설정하려면 환경 변수 %{config}를 설정하고 서버를 재시작하십시오.",
|
||||
"transcodingEnabled": "Navidrome은 현재 %{config} 설정으로 실행되며, 웹 인터페이스의 트랜스코드 설정에 따라 명령을 실행할 수 있습니다.\n보안상의 이유로 이 설정은 트랜스코드 설정을 변경할 때만 활성화하는 것을 권장합니다.",
|
||||
"songsAddedToPlaylist": "플레이리스트에 1곡 추가되었습니다 |||| 플레이리스트에 %{smart_count}곡 추가되었습니다",
|
||||
"noPlaylistsAvailable": "사용 가능하지 않음",
|
||||
"delete_user_title": "'%{name}' 삭제",
|
||||
"delete_user_content": "이 사용자와 그의 모든 데이터(플레이리스트 및 설정 등)를 삭제하시겠습니까?",
|
||||
"notifications_blocked": "브라우저의 설정으로 이 사이트의 알림이 차단되어 있습니다",
|
||||
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않습니다",
|
||||
"lastfmLinkSuccess": "Last.fm과 연결되어 scrobble이 활성화되었습니다",
|
||||
"lastfmLinkFailure": "Last.fm과 연결할 수 없습니다",
|
||||
"lastfmUnlinkSuccess": "설정이 해제되어 Last.fm으로의 scrobble이 비활성화되었습니다",
|
||||
"lastfmUnlinkFailure": "Last.fm과 연결 해제를 실패했습니다",
|
||||
"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": "%{user}에 대한 scrobbling 설정이 성공적으로 완료되었습니다",
|
||||
"listenBrainzLinkFailure": "ListenBrainz와 연결에 실패했습니다: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz와의 연결과 scrobbling이 비활성화되었습니다",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz와의 연결 해제를 실패했습니다",
|
||||
"downloadOriginalFormat": "원본 형식으로 다운로드",
|
||||
"shareOriginalFormat": "원본 형식으로 공유",
|
||||
"lastfmLink": "더 읽기...",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz가 성공적으로 연결되었고 스크로블링이 사용자로 활성화되었음: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz를 연결할 수 없음: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz가 연결 해제되었고 스크로블링이 비활성화되었음",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz를 연결 해제할 수 없음",
|
||||
"downloadOriginalFormat": "오리지널 형식으로 다운로드",
|
||||
"shareOriginalFormat": "오리지널 형식으로 공유",
|
||||
"shareDialogTitle": "%{resource} '%{name}' 공유",
|
||||
"shareBatchDialogTitle": "1 %{resource} 공유 |||| %{smart_count} %{resource} 공유",
|
||||
"shareSuccess": "복사되었습니다: %{url}",
|
||||
"shareFailure": "복사하지 못했습니다 %{url}",
|
||||
"downloadDialogTitle": "다운로드 %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": ""
|
||||
"shareSuccess": "URL이 클립보드에 복사됨: %{url}",
|
||||
"shareFailure": "%{url}을 클립보드에 복사하는 중 오류 발생",
|
||||
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
|
||||
"shareCopyToClipboard": "클립보드에 복사: Ctrl+C, Enter"
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
@@ -385,46 +385,46 @@
|
||||
"options": {
|
||||
"theme": "테마",
|
||||
"language": "언어",
|
||||
"defaultView": "기본 뷰",
|
||||
"defaultView": "기본 보기",
|
||||
"desktop_notifications": "데스크톱 알림",
|
||||
"lastfmScrobbling": "Last.fm으로 scrobble하기",
|
||||
"listenBrainzScrobbling": "ListenBrainz로 scrobble하기",
|
||||
"replaygain": "ReplayGain 모드",
|
||||
"preAmp": "프리앰프",
|
||||
"lastfmScrobbling": "Last.fm으로 스크로블",
|
||||
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
|
||||
"replaygain": "리플레이게인 모드",
|
||||
"preAmp": "리플레이게인 프리앰프 (dB)",
|
||||
"gain": {
|
||||
"none": "비활성화",
|
||||
"album": "앨범 Gain 사용",
|
||||
"track": "트랙 Gain 사용"
|
||||
"album": "앨범 게인 사용",
|
||||
"track": "트랙 게인 사용"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "앨범",
|
||||
"about": "상세 정보",
|
||||
"playlists": "플레이리스트",
|
||||
"sharedPlaylists": "공유된 플레이리스트"
|
||||
"about": "정보",
|
||||
"playlists": "재생목록",
|
||||
"sharedPlaylists": "공유된 재생목록"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "재생 목록",
|
||||
"playListsText": "대기열 재생",
|
||||
"openText": "열기",
|
||||
"closeText": "닫기",
|
||||
"notContentText": "음악이 없습니다",
|
||||
"clickToPlayText": "클릭하여 재생",
|
||||
"clickToPauseText": "일시 정지",
|
||||
"nextTrackText": "다음 곡",
|
||||
"previousTrackText": "이전 곡",
|
||||
"reloadText": "새로 고침",
|
||||
"volumeText": "음량",
|
||||
"notContentText": "음악 없음",
|
||||
"clickToPlayText": "재생하려면 클릭",
|
||||
"clickToPauseText": "일시 중지하려면 클릭",
|
||||
"nextTrackText": "다음 트랙",
|
||||
"previousTrackText": "이전 트랙",
|
||||
"reloadText": "다시 로드하기",
|
||||
"volumeText": "볼륨",
|
||||
"toggleLyricText": "가사 전환",
|
||||
"toggleMiniModeText": "최소화",
|
||||
"destroyText": "삭제",
|
||||
"destroyText": "제거",
|
||||
"downloadText": "다운로드",
|
||||
"removeAudioListsText": "목록 비우기",
|
||||
"clickToDeleteText": "클릭하여 %{name} 삭제",
|
||||
"emptyLyricText": "가사가 없습니다",
|
||||
"removeAudioListsText": "오디오 목록 삭제",
|
||||
"clickToDeleteText": "%{name}을(를) 삭제하려면 클릭",
|
||||
"emptyLyricText": "가사 없음",
|
||||
"playModeText": {
|
||||
"order": "순서대로",
|
||||
"orderLoop": "반복",
|
||||
"singleLoop": "한 곡 반복",
|
||||
"singleLoop": "노래 하나 반복",
|
||||
"shufflePlay": "셔플"
|
||||
}
|
||||
},
|
||||
@@ -437,24 +437,24 @@
|
||||
},
|
||||
"activity": {
|
||||
"title": "활동",
|
||||
"totalScanned": "스캔된 폴더",
|
||||
"totalScanned": "스캔된 전체 폴더",
|
||||
"quickScan": "빠른 스캔",
|
||||
"fullScan": "전체 스캔",
|
||||
"serverUptime": "서버 가동 시간",
|
||||
"serverDown": "서버 오프라인"
|
||||
"serverDown": "오프라인"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 단축키",
|
||||
"hotkeys": {
|
||||
"show_help": "도움말 표시",
|
||||
"toggle_menu": "사이드바 표시/숨기기",
|
||||
"toggle_play": "재생/정지",
|
||||
"prev_song": "이전 곡",
|
||||
"next_song": "다음 곡",
|
||||
"vol_up": "음량 높이기",
|
||||
"vol_down": "음량 낮추기",
|
||||
"toggle_love": "별표 토글",
|
||||
"current_song": "현재 곡으로 이동"
|
||||
"show_help": "이 도움말 표시",
|
||||
"toggle_menu": "메뉴 사이드바 전환",
|
||||
"toggle_play": "재생 / 일시 중지",
|
||||
"prev_song": "이전 노래",
|
||||
"next_song": "다음 노래",
|
||||
"vol_up": "볼륨 높이기",
|
||||
"vol_down": "볼륨 낮추기",
|
||||
"toggle_love": "이 트랙을 즐겨찾기에 추가",
|
||||
"current_song": "현재 노래로 이동"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
466
resources/i18n/sr.json
Normal file
466
resources/i18n/sr.json
Normal file
@@ -0,0 +1,466 @@
|
||||
|
||||
{
|
||||
"languageName": "српски",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Песма |||| Песме",
|
||||
"fields": {
|
||||
"albumArtist": "Уметник албума",
|
||||
"duration": "Трајање",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Пуштано",
|
||||
"title": "Наслов",
|
||||
"artist": "Уметник",
|
||||
"album": "Албум",
|
||||
"path": "Путања фајла",
|
||||
"genre": "Жанр",
|
||||
"compilation": "Компилација",
|
||||
"year": "Година",
|
||||
"size": "Величина фајла",
|
||||
"updatedAt": "Ажурирано",
|
||||
"bitRate": "Битски проток",
|
||||
"channels": "Канала",
|
||||
"discSubtitle": "Поднаслов диска",
|
||||
"starred": "Омиљено",
|
||||
"comment": "Коментар",
|
||||
"rating": "Рејтинг",
|
||||
"quality": "Квалитет",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Последње пуштано",
|
||||
"createdAt": "Датум додавања"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Пусти касније",
|
||||
"playNow": "Пусти одмах",
|
||||
"addToPlaylist": "Додај у плејлисту",
|
||||
"shuffleAll": "Измешај све",
|
||||
"download": "Преузми",
|
||||
"playNext": "Пусти наредно",
|
||||
"info": "Прикажи инфо"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Албум |||| Албуми",
|
||||
"fields": {
|
||||
"albumArtist": "Уметник албума",
|
||||
"artist": "Уметник",
|
||||
"duration": "Трајање",
|
||||
"songCount": "Песме",
|
||||
"playCount": "Пуштано",
|
||||
"size": "Величина",
|
||||
"name": "Назив",
|
||||
"genre": "Жанр",
|
||||
"compilation": "Компилација",
|
||||
"year": "Година",
|
||||
"originalDate": "Оригинално",
|
||||
"releaseDate": "Објављено",
|
||||
"releases": "Издање|||| Издања",
|
||||
"released": "Објављено",
|
||||
"updatedAt": "Ажурирано",
|
||||
"comment": "Коментар",
|
||||
"rating": "Рејтинг",
|
||||
"createdAt": "Датум додавања"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Пусти",
|
||||
"playNext": "Пусти наредно",
|
||||
"addToQueue": "Пусти касније",
|
||||
"share": "Дели",
|
||||
"shuffle": "Измешај",
|
||||
"addToPlaylist": "Додај у плејлисту",
|
||||
"download": "Преузми",
|
||||
"info": "Прикажи инфо"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Све",
|
||||
"random": "Насумично",
|
||||
"recentlyAdded": "Додато недавно",
|
||||
"recentlyPlayed": "Пуштано недавно",
|
||||
"mostPlayed": "Најчешће пуштано",
|
||||
"starred": "Омиљено",
|
||||
"topRated": "Најбоље рангирано"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Уметник |||| Уметници",
|
||||
"fields": {
|
||||
"name": "Име",
|
||||
"albumCount": "Број албума",
|
||||
"songCount": "Број песама",
|
||||
"size": "Величина",
|
||||
"playCount": "Пуштано",
|
||||
"rating": "Рејтинг",
|
||||
"genre": "Жанр"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Корисник |||| Корисници",
|
||||
"fields": {
|
||||
"userName": "Корисничко име",
|
||||
"isAdmin": "Да ли је Админ",
|
||||
"lastLoginAt": "Последња пријава",
|
||||
"updatedAt": "Ажурирано",
|
||||
"name": "Име",
|
||||
"password": "Лозинка",
|
||||
"createdAt": "Креирана",
|
||||
"changePassword": "Измени лозинку?",
|
||||
"currentPassword": "Текућа лозинка",
|
||||
"newPassword": "Нова лозинка",
|
||||
"token": "Жетон"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Измене вашег имена ће постати видљиве након следеће пријаве"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Корисник креиран",
|
||||
"updated": "Корисник ажуриран",
|
||||
"deleted": "Корисник обрисан"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.",
|
||||
"clickHereForToken": "Кликните овде да преузмете свој жетон"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Плејер |||| Плејери",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"transcodingId": "Транскодирање",
|
||||
"maxBitRate": "Макс. битски проток",
|
||||
"client": "Клијент",
|
||||
"userName": "Корисничко име",
|
||||
"lastSeen": "последњи пут виђен",
|
||||
"reportRealPath": "Пријављуј реалну путању",
|
||||
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Транскодирање |||| Транскодирања",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"targetFormat": "Циљни формат",
|
||||
"defaultBitRate": "Подразумевани битски проток",
|
||||
"command": "Команда"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Плејлиста |||| Плејлисте",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"duration": "Трајање",
|
||||
"ownerName": "Власник",
|
||||
"public": "Јавна",
|
||||
"updatedAt": "Ажурирана",
|
||||
"createdAt": "Креирана",
|
||||
"songCount": "Песме",
|
||||
"comment": "Коментар",
|
||||
"sync": "Ауто-увоз",
|
||||
"path": "Увоз из"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Изабери плејлисту",
|
||||
"addNewPlaylist": "Креирај \"%{name}\"",
|
||||
"export": "Извоз",
|
||||
"makePublic": "Учини јавном",
|
||||
"makePrivate": "Учини приватном"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Додај дуплиране песме",
|
||||
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Радио |||| Радији",
|
||||
"fields": {
|
||||
"name": "Назив",
|
||||
"streamUrl": "URL тока",
|
||||
"homePageUrl": "URL почетне странице",
|
||||
"updatedAt": "Ажурирано",
|
||||
"createdAt": "Креирано"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Пусти одмах"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Дељење |||| Дељења",
|
||||
"fields": {
|
||||
"username": "Поделио",
|
||||
"url": "URL",
|
||||
"description": "Опис",
|
||||
"downloadable": "Допушта се преузимање?",
|
||||
"contents": "Садржај",
|
||||
"expiresAt": "Истиче",
|
||||
"lastVisitedAt": "Последњи пут посећено",
|
||||
"visitCount": "Број посета",
|
||||
"format": "Формат",
|
||||
"maxBitRate": "Макс. битски проток",
|
||||
"updatedAt": "Ажурирано",
|
||||
"createdAt": "Креирано"
|
||||
},
|
||||
"notifications": {
|
||||
},
|
||||
"actions": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Хвала што сте инсталирали Navidrome!",
|
||||
"welcome2": "За почетак, креирајте админ корисника",
|
||||
"confirmPassword": "Потврдите лозинку",
|
||||
"buttonCreateAdmin": "Креирај админа",
|
||||
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
|
||||
"user_menu": "Профил",
|
||||
"username": "Корисничко име",
|
||||
"password": "Лозинка",
|
||||
"sign_in": "Пријави се",
|
||||
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
|
||||
"logout": "Одјави се"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Молимо вас да користите само слова и цифре",
|
||||
"passwordDoesNotMatch": "Лозинка се не подудара",
|
||||
"required": "Неопходно",
|
||||
"minLength": "Мора да буде барем %{min} карактера",
|
||||
"maxLength": "Мора да буде %{max} карактера или мање",
|
||||
"minValue": "Мора да буде барем %{min}",
|
||||
"maxValue": "Мора да буде %{max} или мање",
|
||||
"number": "Мора да буде број",
|
||||
"email": "Мора да буде исправна и-мејл адреса",
|
||||
"oneOf": "Мора да буде једно од: %{options}",
|
||||
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
|
||||
"unique": "Мора да буде јединствено",
|
||||
"url": "Мора да буде исправна URL адреса"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Додај филтер",
|
||||
"add": "Додај",
|
||||
"back": "Иди назад",
|
||||
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Откажи",
|
||||
"clear_input_value": "Обриши вредност",
|
||||
"clone": "Клонирај",
|
||||
"confirm": "Потврди",
|
||||
"create": "Креирај",
|
||||
"delete": "Обриши",
|
||||
"edit": "Уреди",
|
||||
"export": "Извези",
|
||||
"list": "Листа",
|
||||
"refresh": "Освежи",
|
||||
"remove_filter": "Уклони овај филтер",
|
||||
"remove": "Уклони",
|
||||
"save": "Сачувај",
|
||||
"search": "Тражи",
|
||||
"show": "Прикажи",
|
||||
"sort": "Сортирај",
|
||||
"undo": "Поништи",
|
||||
"expand": "Развиј",
|
||||
"close": "Затвори",
|
||||
"open_menu": "Отвори мени",
|
||||
"close_menu": "Затвори мени",
|
||||
"unselect": "Уклони избор",
|
||||
"skip": "Прескочи",
|
||||
"share": "Подели",
|
||||
"download": "Преузми"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Да",
|
||||
"false": "Не"
|
||||
},
|
||||
"page": {
|
||||
"create": "Креирај %{name}",
|
||||
"dashboard": "Контролна табла",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Нешто је пошло наопако",
|
||||
"list": "%{name}",
|
||||
"loading": "Учитава се",
|
||||
"not_found": "Није пронађено",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Још увек нема %{name}.",
|
||||
"invite": "Желите ли да се дода?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
|
||||
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
|
||||
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Не могу да се нађу подаци референци.",
|
||||
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
|
||||
"single_missing": "Изгледа да придружена референца више није доступна."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Сакриј лозинку",
|
||||
"toggle_hidden": "Прикажи лозинку"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "О програму",
|
||||
"are_you_sure": "Да ли сте сигурни?",
|
||||
"bulk_delete_content": "Да ли заиста желите да обришете %{name}? |||| Да ли заиста желите да обришете %{smart_count} ставке?",
|
||||
"bulk_delete_title": "Брисање %{name} |||| Брисање %{smart_count} %{name}",
|
||||
"delete_content": "Да ли заиста желите да обришете ову ставку?",
|
||||
"delete_title": "Брисање %{name} #%{id}",
|
||||
"details": "Детаљи",
|
||||
"error": "Дошло је до клијентске грешке и ваш захтев није могао да се изврши.",
|
||||
"invalid_form": "Формулар није исправан. Молимо вас да исправите грешке",
|
||||
"loading": "Страница се учитава, сачекајте мало",
|
||||
"no": "Не",
|
||||
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
|
||||
"yes": "Да",
|
||||
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Није пронађен ниједан резултат",
|
||||
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
|
||||
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
|
||||
"page_out_from_end": "Не може да се иде након последње странице",
|
||||
"page_out_from_begin": "Не може да се иде испред странице 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
|
||||
"page_rows_per_page": "Ставки по страници:",
|
||||
"next": "Наредна",
|
||||
"prev": "Претход",
|
||||
"skip_nav": "Прескочи на садржај"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано",
|
||||
"created": "Елемент је креиран",
|
||||
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
|
||||
"bad_item": "Неисправни елемент",
|
||||
"item_doesnt_exist": "Елемент не постоји",
|
||||
"http_error": "Грешка у комуникацији са сервером",
|
||||
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
|
||||
"i18n_error": "Не могу да се учитају преводи за наведени језик",
|
||||
"canceled": "Акција је отказана",
|
||||
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
|
||||
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Колоне за приказ",
|
||||
"layout": "Распоред",
|
||||
"grid": "Мрежа",
|
||||
"table": "Табела"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "НАПОМЕНА",
|
||||
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
|
||||
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.",
|
||||
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
|
||||
"noPlaylistsAvailable": "Није доступна ниједна",
|
||||
"delete_user_title": "Брисање корисника ’%{name}’",
|
||||
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
|
||||
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
|
||||
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
|
||||
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
|
||||
"lastfmLinkFailure": "Last.fm није могао да се повеже",
|
||||
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
|
||||
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
|
||||
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
|
||||
"openIn": {
|
||||
"lastfm": "Отвори у Last.fm",
|
||||
"musicbrainz": "Отвори у MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Прочитај још...",
|
||||
"shareOriginalFormat": "Подели у оригиналном формату",
|
||||
"shareDialogTitle": "Подели %{resource} ’%{name}’",
|
||||
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
|
||||
"shareSuccess": "URL је копиран у клипборд: %{url}",
|
||||
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
|
||||
"downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})",
|
||||
"downloadOriginalFormat": "Преузми у оригиналном формату"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Библиотека",
|
||||
"settings": "Подешавања",
|
||||
"version": "Верзија",
|
||||
"theme": "Тема",
|
||||
"personal": {
|
||||
"name": "Лична",
|
||||
"options": {
|
||||
"theme": "Тема",
|
||||
"language": "Језик",
|
||||
"defaultView": "Подразумевани поглед",
|
||||
"desktop_notifications": "Десктоп обавештења",
|
||||
"lastfmScrobbling": "Скроблуј на Last.fm",
|
||||
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
|
||||
"replaygain": "ReplayGain режим",
|
||||
"preAmp": "ReplayGain претпојачање (dB)",
|
||||
"gain": {
|
||||
"none": "ИскљученоDisabled",
|
||||
"album": "Користи Album појачање",
|
||||
"track": "Користи 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": "Захтеви за функцијама"
|
||||
}
|
||||
},
|
||||
"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": "Додај ову нумеру у омиљене"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-zglob"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -36,6 +37,7 @@ func (s *playlistImporter) processPlaylists(ctx context.Context, dir string) int
|
||||
return count
|
||||
}
|
||||
for _, f := range files {
|
||||
started := time.Now()
|
||||
if strings.HasPrefix(f.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
@@ -47,9 +49,9 @@ func (s *playlistImporter) processPlaylists(ctx context.Context, dir string) int
|
||||
continue
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
log.Debug("Imported smart playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", pls.SongCount)
|
||||
log.Debug("Imported smart playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "elapsed", time.Since(started))
|
||||
} else {
|
||||
log.Debug("Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", pls.SongCount)
|
||||
log.Debug("Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started))
|
||||
}
|
||||
s.cacheWarmer.PreCache(pls.CoverArtID())
|
||||
count++
|
||||
|
||||
@@ -77,6 +77,15 @@ func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *mockedMediaFile) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
var mfs model.MediaFiles
|
||||
for _, path := range paths {
|
||||
mf, _ := r.FindByPath(path)
|
||||
mfs = append(mfs, *mf)
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
type mockedPlaylist struct {
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
0
tests/fixtures/empty.txt
vendored
Normal file
0
tests/fixtures/empty.txt
vendored
Normal file
@@ -79,6 +79,8 @@ const AlbumListTitle = ({ albumListType }) => {
|
||||
return <Title subTitle={title} args={{ smart_count: 2 }} />
|
||||
}
|
||||
|
||||
const randomStartingSeed = Math.random().toString()
|
||||
|
||||
const AlbumList = (props) => {
|
||||
const { width } = props
|
||||
const albumView = useSelector((state) => state.albumView)
|
||||
@@ -88,6 +90,8 @@ const AlbumList = (props) => {
|
||||
const refresh = useRefresh()
|
||||
useResourceRefresh('album')
|
||||
|
||||
const seed = `${randomStartingSeed}-${version}`
|
||||
|
||||
const albumListType = location.pathname
|
||||
.replace(/^\/album/, '')
|
||||
.replace(/^\//, '')
|
||||
@@ -130,7 +134,7 @@ const AlbumList = (props) => {
|
||||
{...props}
|
||||
exporter={false}
|
||||
bulkActionButtons={false}
|
||||
filter={{ seed: version }}
|
||||
filter={{ seed }}
|
||||
actions={<AlbumListActions />}
|
||||
filters={<AlbumFilter />}
|
||||
perPage={perPage}
|
||||
|
||||
@@ -18,8 +18,10 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
|
||||
const qi = {
|
||||
suffix: song.suffix,
|
||||
bitRate: song.bitRate,
|
||||
albumGain: song.rgAlbumGain,
|
||||
trackGain: song.rgTrackGain,
|
||||
rgAlbumGain: song.rgAlbumGain,
|
||||
rgAlbumPeak: song.rgAlbumPeak,
|
||||
rgTrackGain: song.rgTrackGain,
|
||||
rgTrackPeak: song.rgTrackPeak,
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,16 +23,7 @@ import subsonic from '../subsonic'
|
||||
import locale from './locale'
|
||||
import { keyMap } from '../hotkeys'
|
||||
import keyHandlers from './keyHandlers'
|
||||
|
||||
function calculateReplayGain(preAmp, gain, peak) {
|
||||
if (gain === undefined || peak === undefined) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification§ion=19
|
||||
// Normalized to max gain
|
||||
return Math.min(10 ** ((gain + preAmp) / 20), 1 / peak)
|
||||
}
|
||||
import { calculateGain } from '../utils/calculateReplayGain'
|
||||
|
||||
const Player = () => {
|
||||
const theme = useCurrentTheme()
|
||||
@@ -93,40 +84,10 @@ const Player = () => {
|
||||
const current = playerState.current || {}
|
||||
const song = current.song || {}
|
||||
|
||||
let numericGain
|
||||
|
||||
switch (gainInfo.gainMode) {
|
||||
case 'album': {
|
||||
numericGain = calculateReplayGain(
|
||||
gainInfo.preAmp,
|
||||
song.rgAlbumGain,
|
||||
song.rgAlbumPeak,
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'track': {
|
||||
numericGain = calculateReplayGain(
|
||||
gainInfo.preAmp,
|
||||
song.rgTrackGain,
|
||||
song.rgTrackPeak,
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
numericGain = 1
|
||||
}
|
||||
}
|
||||
|
||||
const numericGain = calculateGain(gainInfo, song)
|
||||
gainNode.gain.setValueAtTime(numericGain, context.currentTime)
|
||||
}
|
||||
}, [
|
||||
audioInstance,
|
||||
context,
|
||||
gainNode,
|
||||
gainInfo.gainMode,
|
||||
gainInfo.preAmp,
|
||||
playerState,
|
||||
])
|
||||
}, [audioInstance, context, gainNode, playerState, gainInfo])
|
||||
|
||||
const defaultOptions = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Chip from '@material-ui/core/Chip'
|
||||
import config from '../config'
|
||||
import { makeStyles } from '@material-ui/core'
|
||||
import clsx from 'clsx'
|
||||
import { calculateGain } from '../utils/calculateReplayGain'
|
||||
|
||||
const llFormats = new Set(config.losslessFormats.split(','))
|
||||
const placeholder = 'N/A'
|
||||
@@ -21,7 +22,8 @@ const useStyle = makeStyles(
|
||||
|
||||
export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => {
|
||||
const classes = useStyle()
|
||||
let { suffix, bitRate } = record
|
||||
let { suffix, bitRate, rgAlbumGain, rgAlbumPeak, rgTrackGain, rgTrackPeak } =
|
||||
record
|
||||
let info = placeholder
|
||||
|
||||
if (suffix) {
|
||||
@@ -32,18 +34,26 @@ export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (gainMode !== 'none') {
|
||||
info += ` (${
|
||||
(gainMode === 'album' ? record.albumGain : record.trackGain) + preAmp
|
||||
} dB)`
|
||||
}
|
||||
const extra = useMemo(() => {
|
||||
if (gainMode !== 'none') {
|
||||
const gainValue = calculateGain(
|
||||
{ gainMode, preAmp },
|
||||
{ rgAlbumGain, rgAlbumPeak, rgTrackGain, rgTrackPeak },
|
||||
)
|
||||
// convert normalized gain (after peak) back to dB
|
||||
const toDb = (Math.log10(gainValue) * 20).toFixed(2)
|
||||
return ` (${toDb} dB)`
|
||||
}
|
||||
|
||||
return ''
|
||||
}, [gainMode, preAmp, rgAlbumGain, rgAlbumPeak, rgTrackGain, rgTrackPeak])
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className={clsx(classes.chip, className)}
|
||||
variant="outlined"
|
||||
size={size}
|
||||
label={info}
|
||||
label={`${info}${extra}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,14 @@ describe('<QualityInfo />', () => {
|
||||
expect(screen.getByText('FLAC')).toBeInTheDocument()
|
||||
})
|
||||
it('only render suffix and bitrate for lossy formats', () => {
|
||||
const info = { suffix: 'MP3', bitRate: 320 }
|
||||
const info = {
|
||||
suffix: 'MP3',
|
||||
bitRate: 320,
|
||||
rgAlbumGain: -5,
|
||||
rgAlbumPeak: 1,
|
||||
rgTrackGain: 2.3,
|
||||
rgTrackPeak: 0.5,
|
||||
}
|
||||
render(<QualityInfo record={info} />)
|
||||
expect(screen.getByText('MP3 320')).toBeInTheDocument()
|
||||
})
|
||||
@@ -24,4 +31,50 @@ describe('<QualityInfo />', () => {
|
||||
render(<QualityInfo />)
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||
})
|
||||
it('renders album gain info, no peak limit', () => {
|
||||
render(
|
||||
<QualityInfo
|
||||
gainMode="album"
|
||||
preAmp={0}
|
||||
record={{
|
||||
rgAlbumGain: -5,
|
||||
rgAlbumPeak: 1,
|
||||
rgTrackGain: -2,
|
||||
rgTrackPeak: 0.2,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('N/A (-5.00 dB)')).toBeInTheDocument()
|
||||
})
|
||||
it('renders track gain info, no peak limit capping, preAmp', () => {
|
||||
render(
|
||||
<QualityInfo
|
||||
gainMode="track"
|
||||
preAmp={-1}
|
||||
record={{
|
||||
rgAlbumGain: -5,
|
||||
rgAlbumPeak: 1,
|
||||
rgTrackGain: 2.3,
|
||||
rgTrackPeak: 0.5,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('N/A (1.30 dB)')).toBeInTheDocument()
|
||||
})
|
||||
it('renders gain info limited by peak', () => {
|
||||
render(
|
||||
<QualityInfo
|
||||
gainMode="track"
|
||||
preAmp={-1}
|
||||
record={{
|
||||
suffix: 'FLAC',
|
||||
rgAlbumGain: -5,
|
||||
rgAlbumPeak: 1,
|
||||
rgTrackGain: 2.3,
|
||||
rgTrackPeak: 1,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('FLAC (0.00 dB)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ const nukeCol = {
|
||||
text: '#ebdbb2',
|
||||
textAlt: '#bdae93',
|
||||
icon: '#b8bb26',
|
||||
link: '#c22817',
|
||||
link: '#c44129',
|
||||
border: '#a89984',
|
||||
}
|
||||
|
||||
@@ -181,12 +181,10 @@ export default {
|
||||
systemNameLink: {
|
||||
color: nukeCol['text'],
|
||||
},
|
||||
icon: {},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
backgroundColor: nukeCol['text'],
|
||||
backgroundColor: nukeCol['secondary'],
|
||||
},
|
||||
avatar: {},
|
||||
button: {
|
||||
boxShadow: '3px 3px 5px #000000a3',
|
||||
},
|
||||
|
||||
31
ui/src/utils/calculateReplayGain.js
Normal file
31
ui/src/utils/calculateReplayGain.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const calculateReplayGain = (preAmp, gain, peak) => {
|
||||
if (gain === undefined || peak === undefined) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification§ion=19
|
||||
// Normalized to max gain
|
||||
return Math.min(10 ** ((gain + preAmp) / 20), 1 / peak)
|
||||
}
|
||||
|
||||
export const calculateGain = (gainInfo, song) => {
|
||||
switch (gainInfo.gainMode) {
|
||||
case 'album': {
|
||||
return calculateReplayGain(
|
||||
gainInfo.preAmp,
|
||||
song.rgAlbumGain,
|
||||
song.rgAlbumPeak,
|
||||
)
|
||||
}
|
||||
case 'track': {
|
||||
return calculateReplayGain(
|
||||
gainInfo.preAmp,
|
||||
song.rgTrackGain,
|
||||
song.rgTrackPeak,
|
||||
)
|
||||
}
|
||||
default: {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ func SetSeed(id string, seed string) {
|
||||
instance.SetSeed(id, seed)
|
||||
}
|
||||
|
||||
func CurrentSeed(id string) string {
|
||||
instance.mutex.RLock()
|
||||
defer instance.mutex.RUnlock()
|
||||
return instance.seeds[id]
|
||||
}
|
||||
|
||||
func HashFunc() func(id, str string) uint64 {
|
||||
return instance.HashFunc()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package slice
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"iter"
|
||||
)
|
||||
|
||||
func Map[T any, R any](t []T, mapFunc func(T) R) []R {
|
||||
r := make([]R, len(t))
|
||||
for i, e := range t {
|
||||
@@ -79,3 +86,57 @@ func RangeByChunks[T any](items []T, chunkSize int, cb func([]T) error) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LinesFrom(reader io.Reader) iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Split(scanLines)
|
||||
for scanner.Scan() {
|
||||
if !yield(scanner.Text()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// From https://stackoverflow.com/a/41433698
|
||||
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
|
||||
if data[i] == '\n' {
|
||||
// We have a line terminated by single newline.
|
||||
return i + 1, data[0:i], nil
|
||||
}
|
||||
advance = i + 1
|
||||
if len(data) > i+1 && data[i+1] == '\n' {
|
||||
advance += 1
|
||||
}
|
||||
return advance, data[0:i], nil
|
||||
}
|
||||
// If we're at EOF, we have a final, non-terminated line. Return it.
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
// Request more data.
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
func CollectChunks[T any](n int, it iter.Seq[T]) iter.Seq[[]T] {
|
||||
return func(yield func([]T) bool) {
|
||||
var s []T
|
||||
for x := range it {
|
||||
s = append(s, x)
|
||||
if len(s) >= n {
|
||||
if !yield(s) {
|
||||
return
|
||||
}
|
||||
s = nil
|
||||
}
|
||||
}
|
||||
if len(s) > 0 {
|
||||
yield(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package slice_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestSlice(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Slice Suite")
|
||||
}
|
||||
@@ -90,4 +94,32 @@ var _ = Describe("Slice Utils", func() {
|
||||
Expect(chunks[1]).To(HaveExactElements("d", "e"))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("LinesFrom",
|
||||
func(path string, expected int) {
|
||||
count := 0
|
||||
file, _ := os.Open(path)
|
||||
defer file.Close()
|
||||
for _ = range slice.LinesFrom(file) {
|
||||
count++
|
||||
}
|
||||
Expect(count).To(Equal(expected))
|
||||
},
|
||||
Entry("returns empty slice for an empty input", "tests/fixtures/empty.txt", 0),
|
||||
Entry("returns the lines of a file", "tests/fixtures/playlists/pls1.m3u", 3),
|
||||
Entry("returns empty if file does not exist", "tests/fixtures/NON-EXISTENT", 0),
|
||||
)
|
||||
|
||||
DescribeTable("CollectChunks",
|
||||
func(input []int, n int, expected [][]int) {
|
||||
result := [][]int{}
|
||||
for chunks := range slice.CollectChunks[int](n, slices.Values(input)) {
|
||||
result = append(result, chunks)
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
},
|
||||
Entry("returns empty slice for an empty input", []int{}, 1, [][]int{}),
|
||||
Entry("returns the slice in one chunk if len < chunkSize", []int{1, 2, 3}, 10, [][]int{{1, 2, 3}}),
|
||||
Entry("breaks up the slice if len > chunkSize", []int{1, 2, 3, 4, 5}, 3, [][]int{{1, 2, 3}, {4, 5}}),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user