Compare commits

...

19 Commits

Author SHA1 Message Date
Deluan Quintão
3910e77a7a build(ci): change GitHub release notes (#3300) 2024-09-21 17:00:13 -04:00
Kendall Garner
196557a41a fix(ui): show effective dB of track when playing (#3293)
* show effective db of track when playing

* tests
2024-09-21 16:46:14 -04:00
Caio Cotts
11d96f1da4 fix(ui): sort mappings (#3296)
* fix(ui): update sort mapping for title in mediafile repository

* fix(ui): create sort mapping for username in share repository

* fix(ui): create sort mapping for owner_name in playlist repository

* fix(ui): create sort mapping for username in player repository

* fix(ui): remove sort mapping for track number in mediafile repository

* chore: add todo to change user_name
2024-09-20 21:36:59 -04:00
Deluan
e628aafa4b build(go): set toolchain to latest version 2024-09-20 18:04:36 -04:00
Deluan
ecf934feab fix(subsonic): random albums not reshuffling.
See: https://github.com/navidrome/navidrome/issues/3277#issuecomment-2364269787
2024-09-20 16:59:46 -04:00
Deluan
5b89bf747f fix(server): play queue should not return empty entries for deleted tracks 2024-09-20 11:22:37 -04:00
Ivan Pešić
7a6845fa5a feat(ui): add Serbian translation (#3287) 2024-09-20 08:51:40 -04:00
Deluan
b6433057e9 fix(ui): make random albums order stick when coming back to the grid 2024-09-19 20:16:50 -04:00
Deluan
d0784b6a21 chore(ci): change "update translations" PR title 2024-09-19 17:28:01 -04:00
gruneforth
b0e7941abe fix(ui): fix Nuclear Theme (#3291)
* Add Nuclear Theme

* Fix login screen color & Softened "link" coloring

---------

Co-authored-by: grune <grune@grunk.me>
2024-09-19 17:13:44 -04:00
Deluan Quintão
a02cfbe2a7 fix(ui): update German translation (#3290)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-09-19 14:08:44 -04:00
naiar
04603a1ea2 fix(subsonic): honour PreferSortTag when building indexes for getArtist and getIndexes (#3286)
* fix(scanner): use sort_artist_name when the config PreferSortTags is true - #3285

* revert unwanted modifications

* refactor(server): use cmp.Or to simplify nested ifs

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-19 13:44:29 -04:00
Deluan
50870d3e61 fix(ui): sort by favourited 2024-09-19 13:05:26 -04:00
DDinghoya
27780683aa feat(ui): update Korean translation (#3288) 2024-09-19 12:13:50 -04:00
Deluan
5baf0b80aa fix(ui): sort playlist by song duration (#3284) 2024-09-19 08:45:49 -04:00
Deluan
46be041e7b fix(scanner): improve M3U playlist import times (#2706) 2024-09-18 20:12:12 -04:00
Kendall Garner
ee2e04b832 fix(ui): random seed for album list on page reload (#3279)
* random seed for album list on page reload

* Nit: inline variable

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-18 12:35:13 -04:00
Kendall Garner
1ba390a72a random -> SEEDRAND (#3274) 2024-09-17 17:03:12 -04:00
Deluan Quintão
8134edb5d1 Fix genre and id filters (#3273) 2024-09-17 16:59:55 -04:00
39 changed files with 1309 additions and 400 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package persistence
// Definitions for testing private methods
var GetIndexKey = (*artistRepository).getIndexKey

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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": "Додај ову нумеру у омиљене"
}
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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&section=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
}
}
}

View File

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

View File

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

View File

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