mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-31 19:08:06 -05:00
Compare commits
23 Commits
postgres
...
copilot/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a8a68a78 | ||
|
|
a1ff9f3f2b | ||
|
|
319651d7d1 | ||
|
|
593b5db8e9 | ||
|
|
19365e5ad0 | ||
|
|
69527085db | ||
|
|
9bb933c0d6 | ||
|
|
6f4fa76772 | ||
|
|
9621a40f29 | ||
|
|
df95dffa74 | ||
|
|
a59b59192a | ||
|
|
4f7dc105b0 | ||
|
|
e918e049e2 | ||
|
|
1e8d28ff46 | ||
|
|
a128b3cf98 | ||
|
|
290a9fdeaa | ||
|
|
58b5ed86df | ||
|
|
fe1cee0159 | ||
|
|
3dfaa8cca1 | ||
|
|
0a5abfc1b1 | ||
|
|
c501bc6996 | ||
|
|
0c71842b12 | ||
|
|
e86dc03619 |
18
.github/workflows/pipeline.yml
vendored
18
.github/workflows/pipeline.yml
vendored
@@ -217,7 +217,7 @@ jobs:
|
||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: Upload Binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: navidrome-${{ env.PLATFORM }}
|
||||
path: ./output
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM }}
|
||||
@@ -267,7 +267,7 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/download-artifact@v5
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-windows*
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
du -h binaries/msi/*.msi
|
||||
|
||||
- name: Upload MSI files
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: navidrome-windows-installers
|
||||
path: binaries/msi/*.msi
|
||||
@@ -357,7 +357,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/download-artifact@v5
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-*
|
||||
@@ -383,7 +383,7 @@ jobs:
|
||||
rm ./dist/*.tar.gz ./dist/*.zip
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: packages
|
||||
path: dist/navidrome_0*
|
||||
@@ -406,13 +406,13 @@ jobs:
|
||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||
steps:
|
||||
- name: Download all-packages artifact
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: packages
|
||||
path: ./dist
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: navidrome_linux_${{ matrix.item }}
|
||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||
|
||||
@@ -72,7 +72,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, scannerScanner)
|
||||
library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
|
||||
return router
|
||||
}
|
||||
|
||||
|
||||
220
core/maintenance.go
Normal file
220
core/maintenance.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
type Maintenance interface {
|
||||
// DeleteMissingFiles deletes specific missing files by their IDs
|
||||
DeleteMissingFiles(ctx context.Context, ids []string) error
|
||||
// DeleteAllMissingFiles deletes all files marked as missing
|
||||
DeleteAllMissingFiles(ctx context.Context) error
|
||||
// RefreshAlbums recalculates album attributes from media files
|
||||
RefreshAlbums(ctx context.Context, albumIDs []string) error
|
||||
}
|
||||
|
||||
type maintenanceService struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewMaintenance(ds model.DataStore) Maintenance {
|
||||
return &maintenanceService{
|
||||
ds: ds,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error {
|
||||
return s.deleteMissing(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error {
|
||||
return s.deleteMissing(ctx, nil)
|
||||
}
|
||||
|
||||
// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations
|
||||
func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error {
|
||||
// Track affected album IDs before deletion for refresh
|
||||
affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error tracking affected albums for refresh", err)
|
||||
// Don't fail the operation, just log the warning
|
||||
}
|
||||
|
||||
// Delete missing files within a transaction
|
||||
err = s.ds.WithTx(func(tx model.DataStore) error {
|
||||
if len(ids) == 0 {
|
||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||
return err
|
||||
}
|
||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Run garbage collection to clean up orphaned records
|
||||
if err := s.ds.GC(ctx); err != nil {
|
||||
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh statistics in background
|
||||
s.refreshStatsAsync(ctx, affectedAlbumIDs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
|
||||
// It uses batch queries to minimize database round-trips for efficiency.
|
||||
func (s *maintenanceService) RefreshAlbums(ctx context.Context, albumIDs []string) error {
|
||||
if len(albumIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Refreshing albums", "count", len(albumIDs))
|
||||
|
||||
// Process in chunks to avoid query size limits
|
||||
const chunkSize = 100
|
||||
for i := 0; i < len(albumIDs); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(albumIDs) {
|
||||
end = len(albumIDs)
|
||||
}
|
||||
chunk := albumIDs[i:end]
|
||||
|
||||
if err := s.refreshAlbumChunk(ctx, chunk); err != nil {
|
||||
return fmt.Errorf("refreshing album chunk: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshAlbumChunk processes a single chunk of album IDs
|
||||
func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
|
||||
albumRepo := s.ds.Album(ctx)
|
||||
mfRepo := s.ds.MediaFile(ctx)
|
||||
|
||||
// Batch load existing albums
|
||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"album.id": albumIDs},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading albums: %w", err)
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
albumMap := make(map[string]*model.Album, len(albums))
|
||||
for i := range albums {
|
||||
albumMap[albums[i].ID] = &albums[i]
|
||||
}
|
||||
|
||||
// Batch load all media files for these albums
|
||||
mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"album_id": albumIDs},
|
||||
Sort: "album_id, path",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading media files: %w", err)
|
||||
}
|
||||
|
||||
// Group media files by album ID
|
||||
filesByAlbum := make(map[string]model.MediaFiles)
|
||||
for i := range mediaFiles {
|
||||
albumID := mediaFiles[i].AlbumID
|
||||
filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i])
|
||||
}
|
||||
|
||||
// Recalculate each album from its media files
|
||||
for albumID, oldAlbum := range albumMap {
|
||||
mfs, hasTracks := filesByAlbum[albumID]
|
||||
if !hasTracks {
|
||||
// Album has no tracks anymore, skip (will be cleaned up by GC)
|
||||
log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Recalculate album from media files
|
||||
newAlbum := mfs.ToAlbum()
|
||||
|
||||
// Only update if something changed (avoid unnecessary writes)
|
||||
if !oldAlbum.Equals(newAlbum) {
|
||||
// Preserve original timestamps
|
||||
newAlbum.UpdatedAt = time.Now()
|
||||
newAlbum.CreatedAt = oldAlbum.CreatedAt
|
||||
|
||||
if err := albumRepo.Put(&newAlbum); err != nil {
|
||||
log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
|
||||
// Continue with other albums instead of failing entirely
|
||||
continue
|
||||
}
|
||||
log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAffectedAlbumIDs returns distinct album IDs from missing media files
|
||||
func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) {
|
||||
var filters squirrel.Sqlizer = squirrel.Eq{"missing": true}
|
||||
if len(ids) > 0 {
|
||||
filters = squirrel.And{
|
||||
squirrel.Eq{"missing": true},
|
||||
squirrel.Eq{"id": ids},
|
||||
}
|
||||
}
|
||||
|
||||
mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: filters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract unique album IDs
|
||||
albumIDMap := make(map[string]struct{}, len(mfs))
|
||||
for _, mf := range mfs {
|
||||
if mf.AlbumID != "" {
|
||||
albumIDMap[mf.AlbumID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
albumIDs := make([]string, 0, len(albumIDMap))
|
||||
for id := range albumIDMap {
|
||||
albumIDs = append(albumIDs, id)
|
||||
}
|
||||
|
||||
return albumIDs, nil
|
||||
}
|
||||
|
||||
// refreshStatsAsync refreshes artist and album statistics in background goroutines
|
||||
func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
|
||||
// Refresh artist stats in background
|
||||
go func() {
|
||||
bgCtx := request.AddValues(context.Background(), ctx)
|
||||
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
||||
} else {
|
||||
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
||||
}
|
||||
|
||||
// Refresh album stats in background if we have affected albums
|
||||
if len(affectedAlbumIDs) > 0 {
|
||||
if err := s.RefreshAlbums(bgCtx, affectedAlbumIDs); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err)
|
||||
} else {
|
||||
log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
179
core/maintenance_test.go
Normal file
179
core/maintenance_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Maintenance", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var mfRepo *extendedMediaFileRepo
|
||||
var service Maintenance
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true})
|
||||
|
||||
ds, mfRepo = createTestDataStore()
|
||||
service = NewMaintenance(ds)
|
||||
})
|
||||
|
||||
Describe("DeleteMissingFiles", func() {
|
||||
Context("with specific IDs", func() {
|
||||
It("deletes specific missing files", func() {
|
||||
// Setup: mock missing files with album IDs
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album2", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||
Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"}))
|
||||
})
|
||||
|
||||
It("returns error if deletion fails", func() {
|
||||
mfRepo.deleteMissingError = errors.New("delete failed")
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("delete failed"))
|
||||
})
|
||||
|
||||
It("continues even if album tracking fails", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
// Should not fail, just log warning
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns error if GC fails", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
})
|
||||
|
||||
// Create a wrapper that returns error on GC
|
||||
dsWithGCError := &mockDataStoreWithGCError{MockDataStore: ds}
|
||||
serviceWithError := NewMaintenance(dsWithGCError)
|
||||
|
||||
err := serviceWithError.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("gc failed"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("album ID extraction", func() {
|
||||
It("extracts unique album IDs from missing files", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf3", AlbumID: "album2", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("skips files without album IDs", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album1", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteAllMissingFiles", func() {
|
||||
It("deletes all missing files", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album2", Missing: true},
|
||||
{ID: "mf3", AlbumID: "album3", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteAllMissingFiles(ctx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error if deletion fails", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
err := service.DeleteAllMissingFiles(ctx)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("handles empty result gracefully", func() {
|
||||
mfRepo.SetData(model.MediaFiles{})
|
||||
|
||||
err := service.DeleteAllMissingFiles(ctx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Test helper to create a mock DataStore with controllable behavior
|
||||
func createTestDataStore() (*tests.MockDataStore, *extendedMediaFileRepo) {
|
||||
ds := &tests.MockDataStore{}
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
ds.MockedArtist = tests.CreateMockArtistRepo()
|
||||
|
||||
// Create extended media file repo with DeleteMissing support
|
||||
mfRepo := &extendedMediaFileRepo{
|
||||
MockMediaFileRepo: tests.CreateMockMediaFileRepo(),
|
||||
}
|
||||
ds.MockedMediaFile = mfRepo
|
||||
|
||||
return ds, mfRepo
|
||||
}
|
||||
|
||||
// Extension of MockMediaFileRepo to add DeleteMissing method
|
||||
type extendedMediaFileRepo struct {
|
||||
*tests.MockMediaFileRepo
|
||||
deleteMissingCalled bool
|
||||
deletedIDs []string
|
||||
deleteMissingError error
|
||||
}
|
||||
|
||||
func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
|
||||
m.deleteMissingCalled = true
|
||||
m.deletedIDs = ids
|
||||
if m.deleteMissingError != nil {
|
||||
return m.deleteMissingError
|
||||
}
|
||||
// Actually delete from the mock data
|
||||
for _, id := range ids {
|
||||
delete(m.Data, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wrapper to override GC method to return error
|
||||
type mockDataStoreWithGCError struct {
|
||||
*tests.MockDataStore
|
||||
}
|
||||
|
||||
func (ds *mockDataStoreWithGCError) GC(ctx context.Context) error {
|
||||
return errors.New("gc failed")
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type Share interface {
|
||||
@@ -119,9 +120,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
|
||||
return "", model.ErrNotFound
|
||||
}
|
||||
if len(s.Contents) > 30 {
|
||||
s.Contents = s.Contents[:26] + "..."
|
||||
}
|
||||
|
||||
s.Contents = str.TruncateRunes(s.Contents, 30, "...")
|
||||
|
||||
id, err = r.Persistable.Save(s)
|
||||
return id, err
|
||||
|
||||
@@ -38,6 +38,38 @@ var _ = Describe("Share", func() {
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(entity.ID).To(Equal(id))
|
||||
})
|
||||
|
||||
It("does not truncate ASCII labels shorter than 30 characters", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "456"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("Example Media File"))
|
||||
})
|
||||
|
||||
It("truncates ASCII labels longer than 30 characters", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("Example Media File But The ..."))
|
||||
})
|
||||
|
||||
It("does not truncate CJK labels shorter than 30 runes", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "456"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("青春コンプレックス"))
|
||||
})
|
||||
|
||||
It("truncates CJK labels longer than 30 runes", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
|
||||
@@ -18,6 +18,7 @@ var Set = wire.NewSet(
|
||||
NewShare,
|
||||
NewPlaylists,
|
||||
NewLibrary,
|
||||
NewMaintenance,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE playqueue ADD COLUMN position_int integer;
|
||||
UPDATE playqueue SET position_int = CAST(position as INTEGER) ;
|
||||
ALTER TABLE playqueue DROP COLUMN position;
|
||||
ALTER TABLE playqueue RENAME COLUMN position_int TO position;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
13
go.mod
13
go.mod
@@ -1,9 +1,13 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.25.3
|
||||
go 1.25.4
|
||||
|
||||
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
|
||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
replace (
|
||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
// Using version from main that fixes https://github.com/navidrome/navidrome/issues/4396
|
||||
github.com/tetratelabs/wazero v1.9.0 => github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
@@ -43,7 +47,7 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.27.1
|
||||
github.com/onsi/ginkgo/v2 v2.27.2
|
||||
github.com/onsi/gomega v1.38.2
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
@@ -124,7 +128,6 @@ require (
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
|
||||
12
go.sum
12
go.sum
@@ -186,8 +186,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s=
|
||||
github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
@@ -201,8 +201,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
@@ -267,8 +265,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684 h1:ugT1JTRsK1Jhn95BWilCugyZ1Svsyxm9xSiflOa2e7E=
|
||||
github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
@@ -286,8 +284,6 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
|
||||
@@ -55,6 +55,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
It("returns all records sorted", func() {
|
||||
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
||||
albumAbbeyRoad,
|
||||
albumMultiDisc,
|
||||
albumRadioactivity,
|
||||
albumSgtPeppers,
|
||||
}))
|
||||
@@ -64,6 +65,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumRadioactivity,
|
||||
albumMultiDisc,
|
||||
albumAbbeyRoad,
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("counts the number of mediafiles in the DB", func() {
|
||||
Expect(mr.CountAll()).To(Equal(int64(6)))
|
||||
Expect(mr.CountAll()).To(Equal(int64(10)))
|
||||
})
|
||||
|
||||
It("returns songs ordered by lyrics with a specific title/artist", func() {
|
||||
|
||||
@@ -69,10 +69,12 @@ var (
|
||||
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
|
||||
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
||||
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
|
||||
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
albumMultiDisc,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -94,13 +96,22 @@ var (
|
||||
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
|
||||
})
|
||||
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
|
||||
testSongs = model.MediaFiles{
|
||||
// Multi-disc album tracks (intentionally out of order to test sorting)
|
||||
songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
songRadioactivity,
|
||||
songAntenna,
|
||||
songAntennaWithLyrics,
|
||||
songAntenna2,
|
||||
songDisc2Track11,
|
||||
songDisc1Track01,
|
||||
songDisc2Track01,
|
||||
songDisc1Track02,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -219,4 +219,37 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Playlist Track Sorting", func() {
|
||||
var testPlaylistID string
|
||||
|
||||
AfterEach(func() {
|
||||
if testPlaylistID != "" {
|
||||
Expect(repo.Delete(testPlaylistID)).To(BeNil())
|
||||
testPlaylistID = ""
|
||||
}
|
||||
})
|
||||
|
||||
It("sorts tracks correctly by album (disc and track number)", func() {
|
||||
By("creating a playlist with multi-disc album tracks in arbitrary order")
|
||||
newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
|
||||
// Add tracks in intentionally scrambled order
|
||||
newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("retrieving tracks sorted by album")
|
||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
||||
tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
By("verifying tracks are sorted by disc number then track number")
|
||||
Expect(tracks).To(HaveLen(4))
|
||||
// Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
|
||||
Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
|
||||
Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
|
||||
Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
|
||||
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,7 +55,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name",
|
||||
"album_artist": "order_album_artist_name",
|
||||
"album": "order_album_name, order_album_artist_name",
|
||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
|
||||
"title": "order_title",
|
||||
// To make sure these fields will be whitelisted
|
||||
"duration": "duration",
|
||||
|
||||
@@ -57,6 +57,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
|
||||
r.db = db
|
||||
r.tableName = "user"
|
||||
r.registerModel(&model.User{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"password": invalidFilter(ctx),
|
||||
"name": r.withTableName(startsWithFilter),
|
||||
})
|
||||
|
||||
@@ -559,4 +559,15 @@ var _ = Describe("UserRepository", func() {
|
||||
Expect(user.Libraries[0].ID).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("filters", func() {
|
||||
It("qualifies id filter with table name", func() {
|
||||
r := repo.(*userRepository)
|
||||
qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
|
||||
sel := r.selectUserWithLibraries(qo)
|
||||
query, _, err := r.toSQL(sel)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(query).To(ContainSubstring("user.id = {:p0}"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,8 +31,12 @@
|
||||
"mood": "Estado",
|
||||
"participants": "Participantes adicionais",
|
||||
"tags": "Etiquetas adicionais",
|
||||
"mappedTags": "",
|
||||
"rawTags": "Etiquetas en cru"
|
||||
"mappedTags": "Etiquetas mapeadas",
|
||||
"rawTags": "Etiquetas en cru",
|
||||
"bitDepth": "Calidade de Bit",
|
||||
"sampleRate": "Taxa de mostra",
|
||||
"missing": "Falta",
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ao final da cola",
|
||||
@@ -41,7 +45,8 @@
|
||||
"shuffleAll": "Remexer todo",
|
||||
"download": "Descargar",
|
||||
"playNext": "A continuación",
|
||||
"info": "Obter info"
|
||||
"info": "Obter info",
|
||||
"showInPlaylist": "Mostrar en Lista de reprodución"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -70,7 +75,10 @@
|
||||
"releaseType": "Tipo",
|
||||
"grouping": "Grupos",
|
||||
"media": "Multimedia",
|
||||
"mood": "Estado"
|
||||
"mood": "Estado",
|
||||
"date": "Data de gravación",
|
||||
"missing": "Falta",
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Reproducir",
|
||||
@@ -102,7 +110,8 @@
|
||||
"rating": "Valoración",
|
||||
"genre": "Xénero",
|
||||
"size": "Tamaño",
|
||||
"role": "Rol"
|
||||
"role": "Rol",
|
||||
"missing": "Falta"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista do álbum |||| Artistas do álbum",
|
||||
@@ -117,7 +126,13 @@
|
||||
"mixer": "Mistura |||| Mistura",
|
||||
"remixer": "Remezcla |||| Remezcla",
|
||||
"djmixer": "Mezcla DJs |||| Mezcla DJs",
|
||||
"performer": "Intérprete |||| Intérpretes"
|
||||
"performer": "Intérprete |||| Intérpretes",
|
||||
"maincredit": "Artista do álbum ou Artista |||| Artistas do álbum ou Artistas"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "Barallar",
|
||||
"radio": "Radio",
|
||||
"topSongs": "Cancións destacadas"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -134,10 +149,12 @@
|
||||
"currentPassword": "Contrasinal actual",
|
||||
"newPassword": "Novo contrasinal",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Último acceso"
|
||||
"lastAccessAt": "Último acceso",
|
||||
"libraries": "Bibliotecas"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Os cambios no nome aplicaranse a próxima vez que accedas"
|
||||
"name": "Os cambios no nome aplicaranse a próxima vez que accedas",
|
||||
"libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Creouse a usuaria",
|
||||
@@ -146,7 +163,12 @@
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Escribe o token de usuaria de ListenBrainz",
|
||||
"clickHereForToken": "Preme aquí para obter o token"
|
||||
"clickHereForToken": "Preme aquí para obter o token",
|
||||
"selectAllLibraries": "Seleccionar todas as bibliotecas",
|
||||
"adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -190,11 +212,17 @@
|
||||
"addNewPlaylist": "Crear \"%{name}\"",
|
||||
"export": "Exportar",
|
||||
"makePublic": "Facela Pública",
|
||||
"makePrivate": "Facela Privada"
|
||||
"makePrivate": "Facela Privada",
|
||||
"saveQueue": "Salvar a Cola como Lista de reprodución",
|
||||
"searchOrCreate": "Buscar listas ou escribe para crear nova…",
|
||||
"pressEnterToCreate": "Preme Enter para crear nova lista",
|
||||
"removeFromSelection": "Retirar da selección"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Engadir cancións duplicadas",
|
||||
"song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?"
|
||||
"song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?",
|
||||
"noPlaylistsFound": "Sen listas de reprodución",
|
||||
"noPlaylists": "Sen listas dispoñibles"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -232,13 +260,68 @@
|
||||
"fields": {
|
||||
"path": "Ruta",
|
||||
"size": "Tamaño",
|
||||
"updatedAt": "Desapareceu o"
|
||||
"updatedAt": "Desapareceu o",
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Retirar"
|
||||
"remove": "Retirar",
|
||||
"remove_all": "Retirar todo"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Ficheiro(s) faltantes retirados"
|
||||
},
|
||||
"empty": "Sen ficheiros faltantes"
|
||||
},
|
||||
"library": {
|
||||
"name": "Biblioteca |||| Bibliotecas",
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"path": "Ruta",
|
||||
"remotePath": "Ruta remota",
|
||||
"lastScanAt": "Último escaneado",
|
||||
"songCount": "Cancións",
|
||||
"albumCount": "Álbums",
|
||||
"artistCount": "Artistas",
|
||||
"totalSongs": "Cancións",
|
||||
"totalAlbums": "Álbums",
|
||||
"totalArtists": "Artistas",
|
||||
"totalFolders": "Cartafoles",
|
||||
"totalFiles": "Ficheiros",
|
||||
"totalMissingFiles": "Ficheiros que faltan",
|
||||
"totalSize": "Tamaño total",
|
||||
"totalDuration": "Duración",
|
||||
"defaultNewUsers": "Por defecto para novas usuarias",
|
||||
"createdAt": "Creada",
|
||||
"updatedAt": "Actualizada"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "Información básica",
|
||||
"statistics": "Estatísticas"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Escanear Biblioteca",
|
||||
"manageUsers": "Xestionar acceso das usuarias",
|
||||
"viewDetails": "Ver detalles"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Biblioteca creada correctamente",
|
||||
"updated": "Biblioteca actualizada correctamente",
|
||||
"deleted": "Biblioteca eliminada correctamente",
|
||||
"scanStarted": "Comezou o escaneo da biblioteca",
|
||||
"scanCompleted": "Completouse o escaneado da biblioteca"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Requírese un nome para a biblioteca",
|
||||
"pathRequired": "Requírese unha ruta para a biblioteca",
|
||||
"pathNotDirectory": "A ruta á biblioteca ten que ser un directorio",
|
||||
"pathNotFound": "Non se atopa a ruta á biblioteca",
|
||||
"pathNotAccessible": "A ruta á biblioteca non é accesible",
|
||||
"pathInvalid": "Ruta non válida á biblioteca"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Tes certeza de querer eliminar esta biblioteca? Isto eliminará todos os datos asociados e accesos de usuarias.",
|
||||
"scanInProgress": "Escaneo en progreso…",
|
||||
"noLibrariesAssigned": "Sen bibliotecas asignadas a esta usuaria"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -419,7 +502,11 @@
|
||||
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copiar ao portapapeis: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Retirar ficheiros que faltan",
|
||||
"remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións."
|
||||
"remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións.",
|
||||
"remove_all_missing_title": "Retirar todos os ficheiros que faltan",
|
||||
"remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.",
|
||||
"noSimilarSongsFound": "Sen cancións parecidas",
|
||||
"noTopSongsFound": "Sen cancións destacadas"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -448,7 +535,13 @@
|
||||
"albumList": "Álbums",
|
||||
"about": "Acerca de",
|
||||
"playlists": "Listas de reprodución",
|
||||
"sharedPlaylists": "Listas compartidas"
|
||||
"sharedPlaylists": "Listas compartidas",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Todas as bibliotecas (%{count})",
|
||||
"multipleLibraries": "%{selected} de %{total} Bibliotecas",
|
||||
"selectLibraries": "Seleccionar Bibliotecas",
|
||||
"none": "Ningunha"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Reproducir cola",
|
||||
@@ -485,6 +578,21 @@
|
||||
"disabled": "Desactivado",
|
||||
"waiting": "Agardando"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "Sobre",
|
||||
"config": "Configuración"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Nome",
|
||||
"environmentVariable": "Variable de entorno",
|
||||
"currentValue": "Valor actual",
|
||||
"configurationFile": "Ficheiro de configuración",
|
||||
"exportToml": "Exportar configuración (TOML)",
|
||||
"exportSuccess": "Configuración exportada ao portapapeis no formato TOML",
|
||||
"exportFailed": "Fallou a copia da configuración",
|
||||
"devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)",
|
||||
"devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -493,7 +601,10 @@
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo",
|
||||
"serverUptime": "Servidor a funcionar",
|
||||
"serverDown": "SEN CONEXIÓN"
|
||||
"serverDown": "SEN CONEXIÓN",
|
||||
"scanType": "Tipo",
|
||||
"status": "Erro de escaneado",
|
||||
"elapsedTime": "Tempo transcurrido"
|
||||
},
|
||||
"help": {
|
||||
"title": "Atallos de Navidrome",
|
||||
@@ -508,5 +619,10 @@
|
||||
"toggle_love": "Engadir canción a favoritas",
|
||||
"current_song": "Ir á Canción actual "
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "En reprodución",
|
||||
"empty": "Sen reprodución",
|
||||
"minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos"
|
||||
}
|
||||
}
|
||||
@@ -400,8 +400,8 @@
|
||||
},
|
||||
"albumList": "Album",
|
||||
"about": "Info",
|
||||
"playlists": "Scalette",
|
||||
"sharedPlaylists": "Scalette Condivise"
|
||||
"playlists": "Playlist",
|
||||
"sharedPlaylists": "Playlist Condivise"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Coda",
|
||||
@@ -457,4 +457,4 @@
|
||||
"current_song": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"libraryName": "라이브러리",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
@@ -34,7 +35,8 @@
|
||||
"participants": "추가 참가자",
|
||||
"tags": "추가 태그",
|
||||
"mappedTags": "매핑된 태그",
|
||||
"rawTags": "원시 태그"
|
||||
"rawTags": "원시 태그",
|
||||
"missing": "누락"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "나중에 재생",
|
||||
@@ -56,6 +58,7 @@
|
||||
"playCount": "재생 횟수",
|
||||
"size": "크기",
|
||||
"name": "이름",
|
||||
"libraryName": "라이브러리",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
@@ -73,7 +76,8 @@
|
||||
"releaseType": "유형",
|
||||
"grouping": "그룹",
|
||||
"media": "미디어",
|
||||
"mood": "분위기"
|
||||
"mood": "분위기",
|
||||
"missing": "누락"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "재생",
|
||||
@@ -105,7 +109,8 @@
|
||||
"playCount": "재생 횟수",
|
||||
"rating": "평가",
|
||||
"genre": "장르",
|
||||
"role": "역할"
|
||||
"role": "역할",
|
||||
"missing": "누락"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "앨범 아티스트 |||| 앨범 아티스트들",
|
||||
@@ -120,7 +125,13 @@
|
||||
"mixer": "믹서 |||| 믹서들",
|
||||
"remixer": "리믹서 |||| 리믹서들",
|
||||
"djmixer": "DJ 믹서 |||| DJ 믹서들",
|
||||
"performer": "공연자 |||| 공연자들"
|
||||
"performer": "공연자 |||| 공연자들",
|
||||
"maincredit": "앨범 아티스트 또는 아티스트 |||| 앨범 아티스트들 또는 아티스트들"
|
||||
},
|
||||
"actions": {
|
||||
"topSongs": "인기곡",
|
||||
"shuffle": "셔플",
|
||||
"radio": "라디오"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -137,19 +148,26 @@
|
||||
"changePassword": "비밀번호를 변경할까요?",
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"newPassword": "새 비밀번호",
|
||||
"token": "토큰"
|
||||
"token": "토큰",
|
||||
"libraries": "라이브러리"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "이름 변경 사항은 다음 로그인 시에만 반영됨"
|
||||
"name": "이름 변경 사항은 다음 로그인 시에만 반영됨",
|
||||
"libraries": "이 사용자에 대한 특정 라이브러리를 선택하거나 기본 라이브러리를 사용하려면 비움"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "사용자 생성됨",
|
||||
"updated": "사용자 업데이트됨",
|
||||
"deleted": "사용자 삭제됨"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "관리자가 아닌 사용자의 경우 최소한 하나의 라이브러리를 선택해야 함"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요",
|
||||
"selectAllLibraries": "모든 라이브러리 선택",
|
||||
"adminAutoLibraries": "관리자 사용자는 자동으로 모든 라이브러리에 접속할 수 있음"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -192,12 +210,18 @@
|
||||
"selectPlaylist": "재생목록 선택:",
|
||||
"addNewPlaylist": "\"%{name}\" 만들기",
|
||||
"export": "내보내기",
|
||||
"saveQueue": "재생목록에 대기열 저장",
|
||||
"makePublic": "공개 만들기",
|
||||
"makePrivate": "비공개 만들기"
|
||||
"makePrivate": "비공개 만들기",
|
||||
"searchOrCreate": "재생목록을 검색하거나 입력하여 새 재생목록을 만드세요...",
|
||||
"pressEnterToCreate": "새 재생목록을 만드려면 Enter 키를 누름",
|
||||
"removeFromSelection": "선택에서 제거"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "중복된 노래 추가",
|
||||
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
|
||||
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?",
|
||||
"noPlaylistsFound": "재생목록을 찾을 수 없음",
|
||||
"noPlaylists": "사용 가능한 재생 목록이 없음"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -238,14 +262,68 @@
|
||||
"fields": {
|
||||
"path": "경로",
|
||||
"size": "크기",
|
||||
"libraryName": "라이브러리",
|
||||
"updatedAt": "사라짐"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "제거"
|
||||
"remove": "제거",
|
||||
"remove_all": "모두 제거"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "누락된 파일이 제거되었음"
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"name": "라이브러리 |||| 라이브러리들",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
"path": "경로",
|
||||
"remotePath": "원격 경로",
|
||||
"lastScanAt": "최근 스캔",
|
||||
"songCount": "노래",
|
||||
"albumCount": "앨범",
|
||||
"artistCount": "아티스트",
|
||||
"totalSongs": "노래",
|
||||
"totalAlbums": "앨범",
|
||||
"totalArtists": "아티스트",
|
||||
"totalFolders": "폴더",
|
||||
"totalFiles": "파일",
|
||||
"totalMissingFiles": "누락된 파일",
|
||||
"totalSize": "총 크기",
|
||||
"totalDuration": "기간",
|
||||
"defaultNewUsers": "신규 사용자 기본값",
|
||||
"createdAt": "생성됨",
|
||||
"updatedAt": "업데이트됨"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "기본 정보",
|
||||
"statistics": "통계"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "라이브러리 스캔",
|
||||
"manageUsers": "자용자 접속 관리",
|
||||
"viewDetails": "상세 보기"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "라이브러리가 성공적으로 생성됨",
|
||||
"updated": "라이브러리가 성공적으로 업데이트됨",
|
||||
"deleted": "라이브러리가 성공적으로 삭제됨",
|
||||
"scanStarted": "라이브러리 스캔 스작됨",
|
||||
"scanCompleted": "라이브러리 스캔 완료됨"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "라이브러리 이름이 필요함",
|
||||
"pathRequired": "라이브러리 경로가 필요함",
|
||||
"pathNotDirectory": "라이브러리 경로는 디렉터리여야 함",
|
||||
"pathNotFound": "라이브러리 경로를 찾을 수 없음",
|
||||
"pathNotAccessible": "라이브러리 경로에 접근할 수 없음",
|
||||
"pathInvalid": "잘못된 라이브러리 경로"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "이 라이브러리를 삭제할까요? 삭제하면 연결된 모든 데이터와 사용자 접속 권한이 제거됩니다.",
|
||||
"scanInProgress": "스캔 진행 중...",
|
||||
"noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -398,11 +476,15 @@
|
||||
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
|
||||
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
|
||||
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
|
||||
"noSimilarSongsFound": "비슷한 노래를 찾을 수 없음",
|
||||
"noTopSongsFound": "인기곡을 찾을 수 없음",
|
||||
"noPlaylistsAvailable": "사용 가능한 노래 없음",
|
||||
"delete_user_title": "사용자 '%{name}' 삭제",
|
||||
"delete_user_content": "이 사용자와 해당 사용자의 모든 데이터(재생 목록 및 환경 설정 포함)를 삭제할까요?",
|
||||
"remove_missing_title": "누락된 파일들 제거",
|
||||
"remove_missing_content": "선택한 누락된 파일을 데이터베이스에서 삭제할까요? 삭제하면 재생 횟수 및 평점을 포함하여 해당 파일에 대한 모든 참조가 영구적으로 삭제됩니다.",
|
||||
"remove_all_missing_title": "누락된 모든 파일 제거",
|
||||
"remove_all_missing_content": "데이터베이스에서 누락된 모든 파일을 제거할까요? 이렇게 하면 해당 게임의 플레이 횟수와 평점을 포함한 모든 참조 내용이 영구적으로 삭제됩니다.",
|
||||
"notifications_blocked": "브라우저 설정에서 이 사이트의 알림을 차단하였음",
|
||||
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하고 있지 않음",
|
||||
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음",
|
||||
@@ -429,6 +511,12 @@
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
"librarySelector": {
|
||||
"allLibraries": "모든 라이브러리 (%{count})",
|
||||
"multipleLibraries": "%{selected} / %{total} 라이브러리",
|
||||
"selectLibraries": "라이브러리 선택",
|
||||
"none": "없음"
|
||||
},
|
||||
"settings": "설정",
|
||||
"version": "버전",
|
||||
"theme": "테마",
|
||||
@@ -491,6 +579,21 @@
|
||||
"disabled": "비활성화",
|
||||
"waiting": "대기중"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "정보",
|
||||
"config": "구성"
|
||||
},
|
||||
"config": {
|
||||
"configName": "구성 이름",
|
||||
"environmentVariable": "환경 변수",
|
||||
"currentValue": "현재 값",
|
||||
"configurationFile": "구성 파일",
|
||||
"exportToml": "구성 내보내기 (TOML)",
|
||||
"exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성",
|
||||
"exportFailed": "구성 복사 실패",
|
||||
"devFlagsHeader": "개발 플래그 (변경/삭제 가능)",
|
||||
"devFlagsComment": "이는 실험적 설정이므로 향후 버전에서 제거될 수 있음"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -499,7 +602,15 @@
|
||||
"quickScan": "빠른 스캔",
|
||||
"fullScan": "전체 스캔",
|
||||
"serverUptime": "서버 가동 시간",
|
||||
"serverDown": "오프라인"
|
||||
"serverDown": "오프라인",
|
||||
"scanType": "유형",
|
||||
"status": "스캔 오류",
|
||||
"elapsedTime": "경과 시간"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "현재 재생 중",
|
||||
"empty": "재생 중인 콘텐츠 없음",
|
||||
"minutesAgo": "%{smart_count} 분 전"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 단축키",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"name": "Nummer |||| Nummers",
|
||||
"fields": {
|
||||
"albumArtist": "Album Artiest",
|
||||
"duration": "Lengte",
|
||||
"duration": "Afspeelduur",
|
||||
"trackNumber": "Nummer #",
|
||||
"playCount": "Aantal keren afgespeeld",
|
||||
"title": "Titel",
|
||||
@@ -35,7 +35,8 @@
|
||||
"rawTags": "Onbewerkte tags",
|
||||
"bitDepth": "Bit diepte",
|
||||
"sampleRate": "Sample waarde",
|
||||
"missing": "Ontbrekend"
|
||||
"missing": "Ontbrekend",
|
||||
"libraryName": "Bibliotheek"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
@@ -44,7 +45,8 @@
|
||||
"shuffleAll": "Shuffle alles",
|
||||
"download": "Downloaden",
|
||||
"playNext": "Volgende",
|
||||
"info": "Meer info"
|
||||
"info": "Meer info",
|
||||
"showInPlaylist": "Toon in afspeellijst"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -55,7 +57,7 @@
|
||||
"duration": "Afspeelduur",
|
||||
"songCount": "Nummers",
|
||||
"playCount": "Aantal keren afgespeeld",
|
||||
"name": "Naam",
|
||||
"name": "Titel",
|
||||
"genre": "Genre",
|
||||
"compilation": "Compilatie",
|
||||
"year": "Jaar",
|
||||
@@ -65,9 +67,9 @@
|
||||
"createdAt": "Datum toegevoegd",
|
||||
"size": "Grootte",
|
||||
"originalDate": "Origineel",
|
||||
"releaseDate": "Uitgegeven",
|
||||
"releaseDate": "Uitgave",
|
||||
"releases": "Uitgave |||| Uitgaven",
|
||||
"released": "Uitgegeven",
|
||||
"released": "Uitgave",
|
||||
"recordLabel": "Label",
|
||||
"catalogNum": "Catalogus nummer",
|
||||
"releaseType": "Type",
|
||||
@@ -75,7 +77,8 @@
|
||||
"media": "Media",
|
||||
"mood": "Sfeer",
|
||||
"date": "Opnamedatum",
|
||||
"missing": "Ontbrekend"
|
||||
"missing": "Ontbrekend",
|
||||
"libraryName": "Bibliotheek"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Afspelen",
|
||||
@@ -123,7 +126,13 @@
|
||||
"mixer": "Mixer |||| Mixers",
|
||||
"remixer": "Remixer |||| Remixers",
|
||||
"djmixer": "DJ Mixer |||| DJ Mixers",
|
||||
"performer": "Performer |||| Performers"
|
||||
"performer": "Performer |||| Performers",
|
||||
"maincredit": "Album Artiest of Artiest |||| Album Artiesten or Artiesten"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "Shuffle",
|
||||
"radio": "Radio",
|
||||
"topSongs": "Beste nummers"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -132,7 +141,7 @@
|
||||
"userName": "Gebruikersnaam",
|
||||
"isAdmin": "Is beheerder",
|
||||
"lastLoginAt": "Laatst ingelogd op",
|
||||
"updatedAt": "Laatst gewijzigd op",
|
||||
"updatedAt": "Laatst bijgewerkt op",
|
||||
"name": "Naam",
|
||||
"password": "Wachtwoord",
|
||||
"createdAt": "Aangemaakt op",
|
||||
@@ -140,19 +149,26 @@
|
||||
"currentPassword": "Huidig wachtwoord",
|
||||
"newPassword": "Nieuw wachtwoord",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Meest recente toegang"
|
||||
"lastAccessAt": "Meest recente toegang",
|
||||
"libraries": "Bibliotheken"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Naamswijziging wordt pas zichtbaar bij de volgende login"
|
||||
"name": "Naamswijziging wordt pas zichtbaar bij de volgende login",
|
||||
"libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Aangemaakt door gebruiker",
|
||||
"updated": "Gewijzigd door gebruiker",
|
||||
"deleted": "Gewist door gebruiker"
|
||||
"updated": "Bijgewerkt door gebruiker",
|
||||
"deleted": "Gebruiker verwijderd"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Vul je ListenBrainz gebruikers-token in.",
|
||||
"clickHereForToken": "Klik hier voor je token"
|
||||
"clickHereForToken": "Klik hier voor je token",
|
||||
"selectAllLibraries": "Selecteer alle bibliotheken",
|
||||
"adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -181,10 +197,10 @@
|
||||
"name": "Afspeellijst |||| Afspeellijsten",
|
||||
"fields": {
|
||||
"name": "Titel",
|
||||
"duration": "Lengte",
|
||||
"duration": "Afspeelduur",
|
||||
"ownerName": "Eigenaar",
|
||||
"public": "Publiek",
|
||||
"updatedAt": "Laatst gewijzigd op",
|
||||
"updatedAt": "Laatst bijgewerkt op",
|
||||
"createdAt": "Aangemaakt op",
|
||||
"songCount": "Nummers",
|
||||
"comment": "Commentaar",
|
||||
@@ -197,11 +213,16 @@
|
||||
"export": "Exporteer",
|
||||
"makePublic": "Openbaar maken",
|
||||
"makePrivate": "Privé maken",
|
||||
"saveQueue": "Bewaar wachtrij als playlist"
|
||||
"saveQueue": "Bewaar wachtrij als playlist",
|
||||
"searchOrCreate": "Zoek afspeellijsten of typ om een nieuwe te starten...",
|
||||
"pressEnterToCreate": "Druk Enter om nieuwe afspeellijst te maken",
|
||||
"removeFromSelection": "Verwijder van selectie"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Dubbele nummers toevoegen",
|
||||
"song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?"
|
||||
"song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?",
|
||||
"noPlaylistsFound": "Geen playlists gevonden",
|
||||
"noPlaylists": "Geen playlists beschikbaar"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -210,8 +231,8 @@
|
||||
"name": "Naam",
|
||||
"streamUrl": "Stream URL",
|
||||
"homePageUrl": "Hoofdpagina URL",
|
||||
"updatedAt": "Geüpdate op",
|
||||
"createdAt": "Gecreëerd op"
|
||||
"updatedAt": "Bijgewerkt op",
|
||||
"createdAt": "Aangemaakt op"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Speel nu"
|
||||
@@ -229,8 +250,8 @@
|
||||
"visitCount": "Bezocht",
|
||||
"format": "Formaat",
|
||||
"maxBitRate": "Max. bitrate",
|
||||
"updatedAt": "Geüpdatet op",
|
||||
"createdAt": "Gecreëerd op",
|
||||
"updatedAt": "Bijgewerkt op",
|
||||
"createdAt": "Aangemaakt op",
|
||||
"downloadable": "Downloads toestaan?"
|
||||
}
|
||||
},
|
||||
@@ -239,7 +260,8 @@
|
||||
"fields": {
|
||||
"path": "Pad",
|
||||
"size": "Grootte",
|
||||
"updatedAt": "Verdwenen op"
|
||||
"updatedAt": "Verdwenen op",
|
||||
"libraryName": "Bibliotheek"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Verwijder",
|
||||
@@ -249,6 +271,58 @@
|
||||
"removed": "Ontbrekende bestanden verwijderd"
|
||||
},
|
||||
"empty": "Geen ontbrekende bestanden"
|
||||
},
|
||||
"library": {
|
||||
"name": "Bibliotheek |||| Bibliotheken",
|
||||
"fields": {
|
||||
"name": "Naam",
|
||||
"path": "Pad",
|
||||
"remotePath": "Extern pad",
|
||||
"lastScanAt": "Laatste scan",
|
||||
"songCount": "Nummers",
|
||||
"albumCount": "Albums",
|
||||
"artistCount": "Artiesten",
|
||||
"totalSongs": "Nummers",
|
||||
"totalAlbums": "Albums",
|
||||
"totalArtists": "Artiesten",
|
||||
"totalFolders": "Mappen",
|
||||
"totalFiles": "Bestanden",
|
||||
"totalMissingFiles": "Ontbrekende bestanden",
|
||||
"totalSize": "Totale bestandsgrootte",
|
||||
"totalDuration": "Afspeelduur",
|
||||
"defaultNewUsers": "Standaard voor nieuwe gebruikers",
|
||||
"createdAt": "Aangemaakt",
|
||||
"updatedAt": "Bijgewerkt"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "Basisinformatie",
|
||||
"statistics": "Statistieken"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Scan bibliotheek",
|
||||
"manageUsers": "Beheer gebruikerstoegang",
|
||||
"viewDetails": "Bekijk details"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Bibliotheek succesvol aangemaakt",
|
||||
"updated": "Bibliotheek succesvol bijgewerkt",
|
||||
"deleted": "Bibliotheek succesvol verwijderd",
|
||||
"scanStarted": "Bibliotheekscan is gestart",
|
||||
"scanCompleted": "Bibliotheekscan is voltooid"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Bibliotheek naam is vereist",
|
||||
"pathRequired": "Pad naar bibliotheek is vereist",
|
||||
"pathNotDirectory": "Pad naar bibliotheek moet een map zijn",
|
||||
"pathNotFound": "Pad naar bibliotheek niet gevonden",
|
||||
"pathNotAccessible": "Pad naar bibliotheek is niet toegankelijk",
|
||||
"pathInvalid": "Ongeldig pad naar bibliotheek"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Weet je zeker dat je deze bibliotheek wil verwijderen? Dit verwijdert ook alle gerelateerde data en gebruikerstoegang.",
|
||||
"scanInProgress": "Scan is bezig...",
|
||||
"noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -430,7 +504,9 @@
|
||||
"remove_missing_title": "Verwijder ontbrekende bestanden",
|
||||
"remove_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
|
||||
"remove_all_missing_title": "Verwijder alle ontbrekende bestanden",
|
||||
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen."
|
||||
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
|
||||
"noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
|
||||
"noTopSongsFound": "Geen beste nummers gevonden"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
@@ -459,7 +535,13 @@
|
||||
"albumList": "Albums",
|
||||
"about": "Over",
|
||||
"playlists": "Afspeellijsten",
|
||||
"sharedPlaylists": "Gedeelde afspeellijsten"
|
||||
"sharedPlaylists": "Gedeelde afspeellijsten",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Alle bibliotheken (%{count})",
|
||||
"multipleLibraries": "%{selected} van %{total} bibliotheken",
|
||||
"selectLibraries": "Selecteer bibliotheken",
|
||||
"none": "Geen"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Wachtrij",
|
||||
@@ -468,7 +550,7 @@
|
||||
"notContentText": "Geen muziek",
|
||||
"clickToPlayText": "Klik om af te spelen",
|
||||
"clickToPauseText": "Klik om te pauzeren",
|
||||
"nextTrackText": "Volgende",
|
||||
"nextTrackText": "Volgend nummer",
|
||||
"previousTrackText": "Vorige",
|
||||
"reloadText": "Herladen",
|
||||
"volumeText": "Volume",
|
||||
@@ -496,11 +578,26 @@
|
||||
"disabled": "Uitgeschakeld",
|
||||
"waiting": "Wachten"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "Over",
|
||||
"config": "Configuratie"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Config Naam",
|
||||
"environmentVariable": "Omgevingsvariabele",
|
||||
"currentValue": "Huidige waarde",
|
||||
"configurationFile": "Configuratiebestand",
|
||||
"exportToml": "Exporteer configuratie (TOML)",
|
||||
"exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
|
||||
"exportFailed": "Kopiëren van configuratie mislukt",
|
||||
"devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)",
|
||||
"devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activiteit",
|
||||
"totalScanned": "Totaal gescande folders",
|
||||
"totalScanned": "Totaal gescande mappen",
|
||||
"quickScan": "Snelle scan",
|
||||
"fullScan": "Volledige scan",
|
||||
"serverUptime": "Server uptime",
|
||||
@@ -522,5 +619,10 @@
|
||||
"toggle_love": "Voeg toe aan favorieten",
|
||||
"current_song": "Ga naar huidig nummer"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Speelt nu",
|
||||
"empty": "Er wordt niets afgespeed",
|
||||
"minutesAgo": "%{smart_count} minuut geleden |||| %{smart_count} minuten geleden"
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,17 @@
|
||||
"bpm": "BPM",
|
||||
"playDate": "เล่นล่าสุด",
|
||||
"channels": "ช่อง",
|
||||
"createdAt": "เพิ่มเมื่อ"
|
||||
"createdAt": "เพิ่มเมื่อ",
|
||||
"grouping": "จัดกลุ่ม",
|
||||
"mood": "อารมณ์",
|
||||
"participants": "ผู้มีส่วนร่วม",
|
||||
"tags": "แทกเพิ่มเติม",
|
||||
"mappedTags": "แมพแทก",
|
||||
"rawTags": "แทกเริ่มต้น",
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "แซมเปิ้ลเรต",
|
||||
"missing": "หายไป",
|
||||
"libraryName": "ห้องสมุด"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "เพิ่มในคิว",
|
||||
@@ -35,7 +45,8 @@
|
||||
"shuffleAll": "สุ่มทั้งหมด",
|
||||
"download": "ดาวน์โหลด",
|
||||
"playNext": "เล่นถัดไป",
|
||||
"info": "ดูรายละเอียด"
|
||||
"info": "ดูรายละเอียด",
|
||||
"showInPlaylist": "แสดงในเพลย์ลิสต์"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -58,7 +69,16 @@
|
||||
"originalDate": "วันที่เริ่ม",
|
||||
"releaseDate": "เผยแพร่เมื่อ",
|
||||
"releases": "เผยแพร่ |||| เผยแพร่",
|
||||
"released": "เผยแพร่เมื่อ"
|
||||
"released": "เผยแพร่เมื่อ",
|
||||
"recordLabel": "ป้าย",
|
||||
"catalogNum": "หมายเลขแคตาล็อก",
|
||||
"releaseType": "ประเภท",
|
||||
"grouping": "จัดกลุ่ม",
|
||||
"media": "มีเดีย",
|
||||
"mood": "อารมณ์",
|
||||
"date": "บันทึกเมื่อ",
|
||||
"missing": "หายไป",
|
||||
"libraryName": "ห้องสมุด"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "เล่นทั้งหมด",
|
||||
@@ -89,7 +109,30 @@
|
||||
"playCount": "เล่นแล้ว",
|
||||
"rating": "ความนิยม",
|
||||
"genre": "ประเภท",
|
||||
"size": "ขนาด"
|
||||
"size": "ขนาด",
|
||||
"role": "Role",
|
||||
"missing": "หายไป"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "ศิลปินอัลบั้ม |||| ศิลปินอัลบั้ม",
|
||||
"artist": "ศิลปิน |||| ศิลปิน",
|
||||
"composer": "ผู้แต่ง |||| ผู้แต่ง",
|
||||
"conductor": "คอนดักเตอร์ |||| คอนดักเตอร์",
|
||||
"lyricist": "เนื้อเพลง |||| เนื้อเพลง",
|
||||
"arranger": "ผู้ดำเนินการ |||| ผู้ดำเนินการ",
|
||||
"producer": "ผู้จัด |||| ผู้จัด",
|
||||
"director": "ไดเรกเตอร์ |||| ไดเรกเตอร์",
|
||||
"engineer": "วิศวกร |||| วิศวกร",
|
||||
"mixer": "มิกเซอร์ |||| มิกเซอร์",
|
||||
"remixer": "รีมิกเซอร์ |||| รีมิกเซอร์",
|
||||
"djmixer": "ดีเจมิกเซอร์ |||| ดีเจมิกเซอร์",
|
||||
"performer": "ผู้เล่น |||| ผู้เล่น",
|
||||
"maincredit": "ศิลปิน |||| ศิลปิน"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "เล่นสุ่ม",
|
||||
"radio": "วิทยุ",
|
||||
"topSongs": "เพลงยอดนิยม"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -106,10 +149,12 @@
|
||||
"currentPassword": "รหัสผ่านปัจจุบัน",
|
||||
"newPassword": "รหัสผ่านใหม่",
|
||||
"token": "โทเคน",
|
||||
"lastAccessAt": "เข้าใช้ล่าสุด"
|
||||
"lastAccessAt": "เข้าใช้ล่าสุด",
|
||||
"libraries": "ห้องสมุด"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป"
|
||||
"name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป",
|
||||
"libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "สร้างชื่อผู้ใช้",
|
||||
@@ -118,7 +163,12 @@
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ใส่โทเคน ListenBrainz ของคุณ",
|
||||
"clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ"
|
||||
"clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ",
|
||||
"selectAllLibraries": "เลือกห้องสมุดทั้งหมด",
|
||||
"adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -162,11 +212,17 @@
|
||||
"addNewPlaylist": "สร้าง \"%{name}\"",
|
||||
"export": "ส่งออก",
|
||||
"makePublic": "ทำเป็นสาธารณะ",
|
||||
"makePrivate": "ทำเป็นส่วนตัว"
|
||||
"makePrivate": "ทำเป็นส่วนตัว",
|
||||
"saveQueue": "บันทึกคิวลงเพลย์ลิสต์",
|
||||
"searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่",
|
||||
"pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์",
|
||||
"removeFromSelection": "เอาออกจากที่เลือกไว้"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "เพิ่มเพลงซ้ำ",
|
||||
"song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม"
|
||||
"song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม",
|
||||
"noPlaylistsFound": "ไม่พบเพลย์ลิสต์",
|
||||
"noPlaylists": "ไม่มีเพลย์ลิสต์อยู่"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -198,6 +254,75 @@
|
||||
"createdAt": "สร้างเมื่อ",
|
||||
"downloadable": "อนุญาตให้ดาวโหลด?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "ไฟล์ที่หายไป |||| ไฟล์ที่หายไป",
|
||||
"fields": {
|
||||
"path": "พาร์ท",
|
||||
"size": "ขนาด",
|
||||
"updatedAt": "หายไปจาก",
|
||||
"libraryName": "ห้องสมุด"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "เอาออก",
|
||||
"remove_all": "เอาออกทั้งหมด"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "เอาไฟล์ที่หายไปออกแล้ว"
|
||||
},
|
||||
"empty": "ไม่มีไฟล์หาย"
|
||||
},
|
||||
"library": {
|
||||
"name": "ห้องสมุด |||| ห้องสมุด",
|
||||
"fields": {
|
||||
"name": "ชื่อ",
|
||||
"path": "พาร์ท",
|
||||
"remotePath": "รีโมทพาร์ท",
|
||||
"lastScanAt": "สแกนล่าสุด",
|
||||
"songCount": "เพลง",
|
||||
"albumCount": "อัลบัม",
|
||||
"artistCount": "ศิลปิน",
|
||||
"totalSongs": "เพลง",
|
||||
"totalAlbums": "อัลบัม",
|
||||
"totalArtists": "ศิลปิน",
|
||||
"totalFolders": "แฟ้ม",
|
||||
"totalFiles": "ไฟล์",
|
||||
"totalMissingFiles": "ไฟล์ที่หายไป",
|
||||
"totalSize": "ขนาดทั้งหมด",
|
||||
"totalDuration": "ความยาว",
|
||||
"defaultNewUsers": "ค่าเริ่มต้นผู้ใช้ใหม่",
|
||||
"createdAt": "สร้าง",
|
||||
"updatedAt": "อัพเดท"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "ข้อมูลเบื้องต้น",
|
||||
"statistics": "สถิติ"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "สแกนห้องสมุด",
|
||||
"manageUsers": "ตั้งค่าการเข้าถึง",
|
||||
"viewDetails": "ดูรายละเอียด"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "สร้างห้องสมุดเรียบร้อย",
|
||||
"updated": "อัพเดทห้องสมุดเรียบร้อย",
|
||||
"deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว",
|
||||
"scanStarted": "เริ่มสแกนห้องสมุด",
|
||||
"scanCompleted": "สแกนห้องสมุดเสร็จแล้ว"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง",
|
||||
"pathRequired": "ต้องใส่พาร์ทของห้องสมุด",
|
||||
"pathNotDirectory": "พาร์ทของห้องสมุดต้องเป็นแฟ้ม",
|
||||
"pathNotFound": "ไม่เจอพาร์ทของห้องสมุด",
|
||||
"pathNotAccessible": "ไม่สามารถเข้าพาร์ทของห้องสมุด",
|
||||
"pathInvalid": "พาร์ทห้องสมุดไม่ถูก"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "คุณแน่ใจว่าจะลบห้องสมุดนี้? นี่จะลบข้อมูลและการเข้าถึงของผู้ใช้ที่เกี่ยวข้องทั้งหมด",
|
||||
"scanInProgress": "กำลังสแกน...",
|
||||
"noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -375,7 +500,13 @@
|
||||
"shareSuccess": "คัดลอก URL ไปคลิปบอร์ด: %{url}",
|
||||
"shareFailure": "คัดลอก URL %{url} ไปคลิปบอร์ดผิดพลาด",
|
||||
"downloadDialogTitle": "ดาวโหลด %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter"
|
||||
"shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter",
|
||||
"remove_missing_title": "ลบรายการไฟล์ที่หายไป",
|
||||
"remove_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
|
||||
"remove_all_missing_title": "เอารายการไฟล์ที่หายไปออกทั้งหมด",
|
||||
"remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
|
||||
"noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
|
||||
"noTopSongsFound": "ไม่พบเพลงยอดนิยม"
|
||||
},
|
||||
"menu": {
|
||||
"library": "ห้องสมุดเพลง",
|
||||
@@ -404,7 +535,13 @@
|
||||
"albumList": "อัลบั้ม",
|
||||
"about": "เกี่ยวกับ",
|
||||
"playlists": "เพลย์ลิสต์",
|
||||
"sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน"
|
||||
"sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน",
|
||||
"librarySelector": {
|
||||
"allLibraries": "ห้องสมุด (%{count}) ห้อง",
|
||||
"multipleLibraries": "%{selected} ของ %{total} ห้องสมุด",
|
||||
"selectLibraries": "เลือกห้องสมุด",
|
||||
"none": "ไม่มี"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "คิวเล่น",
|
||||
@@ -441,6 +578,21 @@
|
||||
"disabled": "ปิดการทำงาน",
|
||||
"waiting": "รอ"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "เกี่ยวกับ",
|
||||
"config": "การตั้งค่า"
|
||||
},
|
||||
"config": {
|
||||
"configName": "ชื่อการตั้งค่า",
|
||||
"environmentVariable": "ค่าทั่วไป",
|
||||
"currentValue": "ค่าปัจจุบัน",
|
||||
"configurationFile": "ไฟล์การตั้งค่า",
|
||||
"exportToml": "นำออกการตั้งค่า (TOML)",
|
||||
"exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว",
|
||||
"exportFailed": "คัดลอกการตั้งค่าล้มเหลว",
|
||||
"devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)",
|
||||
"devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -449,7 +601,10 @@
|
||||
"quickScan": "สแกนแบบเร็ว",
|
||||
"fullScan": "สแกนทั้งหมด",
|
||||
"serverUptime": "เซิร์ฟเวอร์ออนไลน์นาน",
|
||||
"serverDown": "ออฟไลน์"
|
||||
"serverDown": "ออฟไลน์",
|
||||
"scanType": "ประเภท",
|
||||
"status": "สแกนผิดพลาด",
|
||||
"elapsedTime": "เวลาที่ใช้"
|
||||
},
|
||||
"help": {
|
||||
"title": "คีย์ลัด Navidrome",
|
||||
@@ -464,5 +619,10 @@
|
||||
"toggle_love": "เพิ่มเพลงนี้ไปยังรายการโปรด",
|
||||
"current_song": "ไปยังเพลงปัจจุบัน"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "กำลังเล่น",
|
||||
"empty": "ไม่มีเพลงเล่น",
|
||||
"minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
|
||||
}
|
||||
}
|
||||
628
resources/i18n/vi.json
Normal file
628
resources/i18n/vi.json
Normal file
@@ -0,0 +1,628 @@
|
||||
{
|
||||
"languageName": "Tiếng Việt",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Tên bài hát",
|
||||
"fields": {
|
||||
"albumArtist": "Nghệ sĩ trong album",
|
||||
"duration": "Thời lượng",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Số lượt phát",
|
||||
"title": "Tên",
|
||||
"artist": "Nghệ sĩ",
|
||||
"album": "Album",
|
||||
"path": "Đường dẫn file",
|
||||
"genre": "Thể loại",
|
||||
"compilation": "Tuyển tập",
|
||||
"year": "Năm",
|
||||
"size": "Kích thước tệp",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"bitRate": "Số bit",
|
||||
"discSubtitle": "Tiêu đề phụ của đĩa",
|
||||
"starred": "Yêu thích",
|
||||
"comment": "Bình luận",
|
||||
"rating": "Đánh giá",
|
||||
"quality": "Chất lượng",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Phát lần cuối",
|
||||
"channels": "Kênh",
|
||||
"createdAt": "Ngày thêm bài hát",
|
||||
"grouping": "Nhóm",
|
||||
"mood": "Tâm trạng",
|
||||
"participants": "Người tham gia bổ sung",
|
||||
"tags": "Tag bổ sung",
|
||||
"mappedTags": "Thẻ đã liên kết",
|
||||
"rawTags": "Thẻ gốc",
|
||||
"bitDepth": "",
|
||||
"sampleRate": "",
|
||||
"missing": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Thêm bài hát vào hàng chờ",
|
||||
"playNow": "Phát ",
|
||||
"addToPlaylist": "Thêm vào danh sách",
|
||||
"shuffleAll": "Ngẫu nhiên Tất cả",
|
||||
"download": "Tải bài hát xuống",
|
||||
"playNext": "Phát tiếp theo",
|
||||
"info": "Lấy thông tin bài hát",
|
||||
"showInPlaylist": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Tên album",
|
||||
"fields": {
|
||||
"albumArtist": "Nghệ sĩ trong album",
|
||||
"artist": "Nghệ sĩ",
|
||||
"duration": "Thời lượng",
|
||||
"songCount": "Số bài hát",
|
||||
"playCount": "Số lượt phát",
|
||||
"name": "Tên",
|
||||
"genre": "Thể loại",
|
||||
"compilation": "Tuyển tập",
|
||||
"year": "Năm",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"comment": "Bình luận",
|
||||
"rating": "Đánh giá",
|
||||
"createdAt": "Ngày thêm album",
|
||||
"size": "Kích cỡ",
|
||||
"originalDate": "Bản gốc",
|
||||
"releaseDate": "Ngày phát hành",
|
||||
"releases": "Bản phát hành |||| Các bản phát hành",
|
||||
"released": "Đã phát hành",
|
||||
"recordLabel": "Hãng đĩa",
|
||||
"catalogNum": "Số Catalog",
|
||||
"releaseType": "Loai",
|
||||
"grouping": "Nhóm",
|
||||
"media": "",
|
||||
"mood": "",
|
||||
"date": "",
|
||||
"missing": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Phát",
|
||||
"playNext": "Tiếp theo",
|
||||
"addToQueue": "Thêm album vào hàng chờ",
|
||||
"shuffle": "phát ngẫu nhiên",
|
||||
"addToPlaylist": "Thêm vào danh sách phát",
|
||||
"download": "Tải Album xuống",
|
||||
"info": "Lấy thông tin album",
|
||||
"share": "Chia sẻ"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Tất cả",
|
||||
"random": "Ngẫu nhiên",
|
||||
"recentlyAdded": "Thêm vào gần đây",
|
||||
"recentlyPlayed": "Đã phát gần đây",
|
||||
"mostPlayed": "Phát nhiều nhất",
|
||||
"starred": "Album Yêu thích",
|
||||
"topRated": "Được đánh giá cao nhất"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Nghệ sĩ",
|
||||
"fields": {
|
||||
"name": "Tên nghệ sĩ",
|
||||
"albumCount": "Số Album",
|
||||
"songCount": "Số bài hát",
|
||||
"playCount": "Số lượt phát",
|
||||
"rating": "Đánh giá",
|
||||
"genre": "Thể loại",
|
||||
"size": "Kích cỡ",
|
||||
"role": "",
|
||||
"missing": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": "",
|
||||
"maincredit": ""
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "",
|
||||
"radio": "",
|
||||
"topSongs": ""
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Người dùng",
|
||||
"fields": {
|
||||
"userName": "Tên người dùng",
|
||||
"isAdmin": "Quản trị viên",
|
||||
"lastLoginAt": "Lần đăng nhập cuối",
|
||||
"updatedAt": "Cập nhật lúc",
|
||||
"name": "Tên người dùng",
|
||||
"password": "Mật khẩu",
|
||||
"createdAt": "Tạo vào",
|
||||
"changePassword": "Đổi mật khẩu ?",
|
||||
"currentPassword": "Mật khẩu hiện tại",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Lần truy cập cuối",
|
||||
"libraries": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Sự thay đổi về tên bạn sẽ có hiệu lực vào lần đăng nhập tiếp theo",
|
||||
"libraries": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Tạo bởi user",
|
||||
"updated": "Cập nhật bởi user",
|
||||
"deleted": "Xóa người dùng"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Nhập token của MusicBrainz",
|
||||
"clickHereForToken": "",
|
||||
"selectAllLibraries": "",
|
||||
"adminAutoLibraries": ""
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Trình phát |||| Các trình phát",
|
||||
"fields": {
|
||||
"name": "Tên trình phát",
|
||||
"transcodingId": "Mã chuyển mã",
|
||||
"maxBitRate": "Bit Rate cao nhất",
|
||||
"client": "",
|
||||
"userName": "Tên người dùng",
|
||||
"lastSeen": "Lần cuối nhìn thấy",
|
||||
"reportRealPath": "Hiện đường dẫn thực",
|
||||
"scrobbleEnabled": ""
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Chuyển đổi định dạng",
|
||||
"fields": {
|
||||
"name": "Tên cấu hình chuyển mã",
|
||||
"targetFormat": "Định dạng cuối",
|
||||
"defaultBitRate": "Số Bit mặc định",
|
||||
"command": "Câu lệnh"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Danh sách phát |||| Các danh sách phát",
|
||||
"fields": {
|
||||
"name": "Tên",
|
||||
"duration": "Thời lượng",
|
||||
"ownerName": "Chủ sở hữu",
|
||||
"public": "Công khai",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"createdAt": "Tạo vào lúc",
|
||||
"songCount": "Số bài hát",
|
||||
"comment": "Bình luận",
|
||||
"sync": "Tự động thêm vào",
|
||||
"path": "Nhập từ"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Chọn 1 danh sách phát",
|
||||
"addNewPlaylist": "Tạo \"%{name}\"",
|
||||
"export": "Xuất danh sách phát",
|
||||
"makePublic": "",
|
||||
"makePrivate": "",
|
||||
"saveQueue": "",
|
||||
"searchOrCreate": "",
|
||||
"pressEnterToCreate": "",
|
||||
"removeFromSelection": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Thêm các bài hát trùng lặp",
|
||||
"song_exist": "Có một số bài hát trùng đang được thêm vào danh sách phát. Bạn muốn thêm các bài trùng hay bỏ qua chúng?",
|
||||
"noPlaylistsFound": "",
|
||||
"noPlaylists": ""
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Radio |||| Radios",
|
||||
"fields": {
|
||||
"name": "Tên",
|
||||
"streamUrl": "Stream URL",
|
||||
"homePageUrl": "URL trang chủ",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"createdAt": "Tạo vào lúc"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Phát ngay"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Chia sẻ |||| Chia sẻ",
|
||||
"fields": {
|
||||
"username": "Chia sẻ bởi",
|
||||
"url": "URL",
|
||||
"description": "Phần mô tả",
|
||||
"contents": "Nội dung",
|
||||
"expiresAt": "Hết hạn",
|
||||
"lastVisitedAt": "Lần mở cuối ",
|
||||
"visitCount": "Lượt ",
|
||||
"format": "Định dạng",
|
||||
"maxBitRate": "Số Bit cao nhất",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"createdAt": "Tạo vào",
|
||||
"downloadable": "Cho phép tải xuống?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"path": "",
|
||||
"size": "",
|
||||
"updatedAt": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": "",
|
||||
"remove_all": ""
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
},
|
||||
"empty": ""
|
||||
},
|
||||
"library": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"path": "",
|
||||
"remotePath": "",
|
||||
"lastScanAt": "",
|
||||
"songCount": "",
|
||||
"albumCount": "",
|
||||
"artistCount": "",
|
||||
"totalSongs": "",
|
||||
"totalAlbums": "",
|
||||
"totalArtists": "",
|
||||
"totalFolders": "",
|
||||
"totalFiles": "",
|
||||
"totalMissingFiles": "",
|
||||
"totalSize": "",
|
||||
"totalDuration": "",
|
||||
"defaultNewUsers": "",
|
||||
"createdAt": "",
|
||||
"updatedAt": ""
|
||||
},
|
||||
"sections": {
|
||||
"basic": "",
|
||||
"statistics": ""
|
||||
},
|
||||
"actions": {
|
||||
"scan": "",
|
||||
"manageUsers": "",
|
||||
"viewDetails": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"deleted": "Xóa thư viện thành công",
|
||||
"scanStarted": "Bắt đầu quét thư viện",
|
||||
"scanCompleted": "Quét thư viện hoàn tất"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "",
|
||||
"pathRequired": "",
|
||||
"pathNotDirectory": "",
|
||||
"pathNotFound": "",
|
||||
"pathNotAccessible": "",
|
||||
"pathInvalid": ""
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "",
|
||||
"scanInProgress": "Đang quét...",
|
||||
"noLibrariesAssigned": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Cảm ơn bạn vì đã sử dụng Navidrome",
|
||||
"welcome2": "Để bắt đầu, hãy tạo một tài khoản quản trị viên.",
|
||||
"confirmPassword": "Xác nhận mật khẩu",
|
||||
"buttonCreateAdmin": "Tạo quản trị viên",
|
||||
"auth_check_error": "Hãy đăng nhập để tiếp tục",
|
||||
"user_menu": "Profile",
|
||||
"username": "Tên người dùng",
|
||||
"password": "Mật khẩu",
|
||||
"sign_in": "Đăng nhập",
|
||||
"sign_in_error": "Xác thực thất bại, hãy thử lại",
|
||||
"logout": "Đăng xuất",
|
||||
"insightsCollectionNote": "Navidrome thu thập dữ liệu sử dụng ẩn danh để giúp cải thiện dự án. Nhấp [here] để tìm hiểu thêm và tắt tính năng này nếu bạn muốn."
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Vui lòng chỉ sử dụng chữ cái và số",
|
||||
"passwordDoesNotMatch": "Mật khẩu không đúng",
|
||||
"required": "Yêu cầu",
|
||||
"minLength": "Ít nhất là %{min} ký tự",
|
||||
"maxLength": "Phải nhiều hơn hoặc bằng hoặc bằng %{max}.",
|
||||
"minValue": "Ít nhất là %{min}",
|
||||
"maxValue": "Phải nhỏ hơn hoặc bằng %{max}",
|
||||
"number": "Phải là một số",
|
||||
"email": "Phải là một email ",
|
||||
"oneOf": "Phải là một trong các lựa chọn sau: %{options}",
|
||||
"regex": "Phải khớp với định dạng cụ thể (regex): %{pattern}",
|
||||
"unique": "Phải đặc biệt",
|
||||
"url": "Phải là một URL hợp lệ"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Thêm bộ lọc",
|
||||
"add": "Thêm",
|
||||
"back": "Quay lại",
|
||||
"bulk_actions": "Đã chọn 1 mục |||| Đã chọn %{smart_count} mục",
|
||||
"cancel": "Hủy",
|
||||
"clear_input_value": "Xóa thiết đặt",
|
||||
"clone": "Nhân bản",
|
||||
"confirm": "Xác nhận",
|
||||
"create": "Tạo",
|
||||
"delete": "Xóa",
|
||||
"edit": "Sửa",
|
||||
"export": "Xuất",
|
||||
"list": "Danh sách",
|
||||
"refresh": "Làm mới",
|
||||
"remove_filter": "Bỏ bộ lọc này",
|
||||
"remove": "Gỡ bỏ",
|
||||
"save": "Lưu lại",
|
||||
"search": "Tìm kiếm",
|
||||
"show": "Hiển thị",
|
||||
"sort": "Lọc",
|
||||
"undo": "Hoàn tác",
|
||||
"expand": "Mở rộng",
|
||||
"close": "Đóng",
|
||||
"open_menu": "Mở menu",
|
||||
"close_menu": "Đóng menu",
|
||||
"unselect": "Bỏ chọn",
|
||||
"skip": "Bỏ qua",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Chia sẻ",
|
||||
"download": "Tải xuống"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Có",
|
||||
"false": "Không"
|
||||
},
|
||||
"page": {
|
||||
"create": "Tạo %{name}",
|
||||
"dashboard": "Trang chủ",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Có gì đó không ổn",
|
||||
"list": "%{name}",
|
||||
"loading": "Đang tải",
|
||||
"not_found": "Không tìm thấy",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Chưa có %{name}",
|
||||
"invite": "Bạn muốn thêm vào không ?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Thả một vài tệp để tải lên hoặc nhấp để chọn",
|
||||
"upload_single": "Thả một file để tải lên hoặc nhấp để chọn nó"
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Thả một vài ảnh để tải lên hoặc nhấp để chọn",
|
||||
"upload_single": "Thả một ảnh để tải lên hoặc nhấp để chọn nó"
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Không thể tìm thấy dữ liệu",
|
||||
"many_missing": "Ít nhất một mục được liên kết không còn tồn tại.",
|
||||
"single_missing": "Tham chiếu liên kết không còn khả dụng nữa."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Ẩn mật khẩu",
|
||||
"toggle_hidden": "Hiện mật khẩu"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Giới thiệu",
|
||||
"are_you_sure": "Bạn chắc chứ ?",
|
||||
"bulk_delete_content": "Bạn có chắc chắn muốn xóa %{name} này không? |||| Bạn có chắc chắn muốn xóa %{smart_count} mục này không??",
|
||||
"bulk_delete_title": "Xóa %{name} đã chọn |||| Xóa %{smart_count} mục %{name}",
|
||||
"delete_content": "Xác nhận xóa ?",
|
||||
"delete_title": "Xóa %{name} #%{id}",
|
||||
"details": "Chi tiết",
|
||||
"error": "Có lỗi xảy ra với client và yêu cầu của bạn không thành công.",
|
||||
"invalid_form": "Biểu mẫu không hợp lệ. Vui lòng kiểm tra lại các lỗi",
|
||||
"loading": "Trang đang được tải, hãy kiên nhận",
|
||||
"no": "Không",
|
||||
"not_found": "Có thể bạn đã nhập sai URL hoặc truy cập vào một liên kết không hợp lệ.",
|
||||
"yes": "Có",
|
||||
"unsaved_changes": "Một số thiết đặt chưa được lưu. Bạn muốn bỏ qua chúng không ?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Không tìm thấy kết quả",
|
||||
"no_more_results": "Số trang %{page} nằm ngoài giới hạn. Hãy thử quay lại trang trước",
|
||||
"page_out_of_boundaries": "Trang %{page} không hợp lệ",
|
||||
"page_out_from_end": "Bạn đang ở trang cuối rồi",
|
||||
"page_out_from_begin": "Không thể quay về trước trang 1",
|
||||
"page_range_info": "%{offsetBegin}–%{offsetEnd} trong tổng số %{total}",
|
||||
"page_rows_per_page": "Số mục mỗi trang :",
|
||||
"next": "Tiếp theo",
|
||||
"prev": "Trước",
|
||||
"skip_nav": "Bỏ qua đến nội dung"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Mục đã được cập nhật |||| %{smart_count} mục đã cập nhật",
|
||||
"created": "Đã tạo mục mới",
|
||||
"deleted": "Đã xóa muc |||| %{smart_count} mục đã xóa",
|
||||
"bad_item": "Mục không đúng",
|
||||
"item_doesnt_exist": "Mục không tồn tại",
|
||||
"http_error": "Lỗi kết nối đến máy chủ",
|
||||
"data_provider_error": "Lỗi dataProvider. Kiểm tra Console để biết thêm chi tiết",
|
||||
"i18n_error": "Không thể tải bản dịch cho ngôn ngữ đã chọn",
|
||||
"canceled": "Hành động đã bị hủy",
|
||||
"logged_out": "Phiên của bạn đã kết thúc, vui lòng kết nối lại.",
|
||||
"new_version": "Có phiên bản mới! Hãy làm mới trang"
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Các cột hiển thị",
|
||||
"layout": "Bố cục",
|
||||
"grid": "Lưới",
|
||||
"table": "Bảng"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "Lưu ý",
|
||||
"transcodingDisabled": "Việc thay đổi cấu hình chuyển mã (transcoding configuration) thông qua giao diện web đã bị vô hiệu hóa vì lý do bảo mật. Nếu bạn muốn chỉnh sửa hoặc thêm tùy chọn chuyển mã, hãy khởi động lại máy chủ kèm theo tùy chọn cấu hình %{config}",
|
||||
"transcodingEnabled": "Navidrome hiện đang chạy với tùy chọn cấu hình %{config}, cho phép thực thi lệnh hệ thống từ phần cài đặt chuyển mã (transcoding) trong giao diện web. Chúng tôi khuyến nghị bạn nên tắt tùy chọn này vì lý do bảo mật, và chỉ bật lại khi cần cấu hình các tùy chọn chuyển mã.",
|
||||
"songsAddedToPlaylist": "Đã thêm 1 bài hát vào danh sách phát |||| Đã thêm %{smart_count} bài hát vào danh sách phát",
|
||||
"noPlaylistsAvailable": "Không có danh sách phát",
|
||||
"delete_user_title": "Xóa người dùng '%{name}'",
|
||||
"delete_user_content": "Bạn có muốn xóa người dùng này và tất cả các dữ liệu của họ không ( bao gồm danh sách phát và các thiết đặt )?",
|
||||
"notifications_blocked": "Bạn đã tắt thông báo trong cài đặt trình duyệt",
|
||||
"notifications_not_available": "Trình duyệt này không hỗ trợ thông báo trên desktop hoặc bạn đang truy cập Navidrome qua http",
|
||||
"lastfmLinkSuccess": "",
|
||||
"lastfmLinkFailure": "",
|
||||
"lastfmUnlinkSuccess": "",
|
||||
"lastfmUnlinkFailure": "",
|
||||
"openIn": {
|
||||
"lastfm": "Mở trong Last.fm",
|
||||
"musicbrainz": "Mở trong MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Đọc thêm...",
|
||||
"listenBrainzLinkSuccess": "",
|
||||
"listenBrainzLinkFailure": "Không thể liên kết với ListenBrainz : %{error}",
|
||||
"listenBrainzUnlinkSuccess": "Đã bỏ liên kết với ListenBrainz và ",
|
||||
"listenBrainzUnlinkFailure": "Không thể liên kết với MusicBrainz",
|
||||
"downloadOriginalFormat": "Tải xuống ở định dạng gốc",
|
||||
"shareOriginalFormat": "Chia sẻ ở định dạng gốc",
|
||||
"shareDialogTitle": "Chia sẻ %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Chia sẻ 1 %{resource} |||| Chia sẻ %{smart_count} %{resource}",
|
||||
"shareSuccess": "URL đã sao chép vào bảng nhớ tạm : %{url}",
|
||||
"shareFailure": "Lỗi khi sao chép URL %{url} vào bảng nhớ tạm",
|
||||
"downloadDialogTitle": "Tải xuống %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Sao chép vào bảng nhớ tạm : Ctrl+C, Enter",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": "",
|
||||
"remove_all_missing_title": "",
|
||||
"remove_all_missing_content": "",
|
||||
"noSimilarSongsFound": "",
|
||||
"noTopSongsFound": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Thư viện",
|
||||
"settings": "Cài đặt",
|
||||
"version": "Phiên bản",
|
||||
"theme": "Theme",
|
||||
"personal": {
|
||||
"name": "Cá nhân hóa",
|
||||
"options": {
|
||||
"theme": "Theme",
|
||||
"language": "Ngôn ngữ",
|
||||
"defaultView": "",
|
||||
"desktop_notifications": "Thông báo trên desktop",
|
||||
"lastfmScrobbling": "",
|
||||
"listenBrainzScrobbling": "",
|
||||
"replaygain": "Chế độ ReplayGain",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"gain": {
|
||||
"none": "Tắt",
|
||||
"album": "Dùng Album Gain",
|
||||
"track": "Dùng Track Gain"
|
||||
},
|
||||
"lastfmNotConfigured": "Khóa API của Last.fm chưa được cấu hình"
|
||||
}
|
||||
},
|
||||
"albumList": "Albums",
|
||||
"about": "Về",
|
||||
"playlists": "Danh sách phát",
|
||||
"sharedPlaylists": "Danh sách phát được chia sẻ",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Tất cả thư viện (%{count})",
|
||||
"multipleLibraries": "",
|
||||
"selectLibraries": "",
|
||||
"none": "Không có"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Danh sách chờ",
|
||||
"openText": "Mở",
|
||||
"closeText": "Thoát",
|
||||
"notContentText": "Không có bài hát",
|
||||
"clickToPlayText": "Nhấp để phát",
|
||||
"clickToPauseText": "Nhấp để tạm dừng",
|
||||
"nextTrackText": "Track tiếp theo",
|
||||
"previousTrackText": "Track trước đó",
|
||||
"reloadText": "Làm mới",
|
||||
"volumeText": "Âm lượng",
|
||||
"toggleLyricText": "Bật lời bài hát",
|
||||
"toggleMiniModeText": "Thu nhỏ",
|
||||
"destroyText": "Xóa",
|
||||
"downloadText": "Tải xuống",
|
||||
"removeAudioListsText": "Xóa danh sách ",
|
||||
"clickToDeleteText": "Nhấp để xóa %{name}",
|
||||
"emptyLyricText": "Không có lời",
|
||||
"playModeText": {
|
||||
"order": "Theo thứ tự",
|
||||
"orderLoop": "Lặp lại",
|
||||
"singleLoop": "Lặp lại một lần",
|
||||
"shufflePlay": "Phát ngẫu nhiên"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Trang chủ",
|
||||
"source": "Mã nguồn",
|
||||
"featureRequests": "Yêu cầu tính năng",
|
||||
"lastInsightsCollection": "Lần thu thập dữ liệu gần nhất",
|
||||
"insights": {
|
||||
"disabled": "Đã tắt",
|
||||
"waiting": "Đang chờ"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "",
|
||||
"config": ""
|
||||
},
|
||||
"config": {
|
||||
"configName": "",
|
||||
"environmentVariable": "",
|
||||
"currentValue": "",
|
||||
"configurationFile": "",
|
||||
"exportToml": "",
|
||||
"exportSuccess": "",
|
||||
"exportFailed": "",
|
||||
"devFlagsHeader": "",
|
||||
"devFlagsComment": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Hoạt động",
|
||||
"totalScanned": "Tổng Folder đã quét",
|
||||
"quickScan": "Quét nhanh",
|
||||
"fullScan": "Quét toàn bộ",
|
||||
"serverUptime": "Server Uptime",
|
||||
"serverDown": "Ngoại tuyến",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": ""
|
||||
},
|
||||
"help": {
|
||||
"title": "Phím tắt của Navidrome",
|
||||
"hotkeys": {
|
||||
"show_help": "Hiện giúp đỡ",
|
||||
"toggle_menu": "Bật thanh phát bên",
|
||||
"toggle_play": "Phát / tạm dừng",
|
||||
"prev_song": "Bài hát trước đó",
|
||||
"next_song": "Bài hát sau đó",
|
||||
"vol_up": "Tăng âm lượng",
|
||||
"vol_down": "Giảm âm lượng",
|
||||
"toggle_love": "Thêm track này vào yêu thích",
|
||||
"current_song": "Đi đến bài hát hiện tại"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"minutesAgo": ""
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
|
||||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
)
|
||||
|
||||
// User-library association endpoints (admin only)
|
||||
func (n *Router) addUserLibraryRoute(r chi.Router) {
|
||||
func (api *Router) addUserLibraryRoute(r chi.Router) {
|
||||
r.Route("/user/{id}/library", func(r chi.Router) {
|
||||
r.Use(parseUserIDMiddleware)
|
||||
r.Get("/", getUserLibraries(n.libs))
|
||||
r.Put("/", setUserLibraries(n.libs))
|
||||
r.Get("/", getUserLibraries(api.libs))
|
||||
r.Put("/", setUserLibraries(api.libs))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ var _ = Describe("Library API", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
@@ -63,45 +63,32 @@ func (r *missingRepository) EntityName() string {
|
||||
return "missing_files"
|
||||
}
|
||||
|
||||
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
|
||||
var err error
|
||||
if len(ids) == 0 {
|
||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||
return err
|
||||
}
|
||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||
})
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = ds.GC(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh artist stats in background after deleting missing files
|
||||
go func() {
|
||||
bgCtx := request.AddValues(context.Background(), r.Context())
|
||||
if _, err := ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
||||
err = maintenance.DeleteAllMissingFiles(ctx)
|
||||
} else {
|
||||
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
||||
err = maintenance.DeleteMissingFiles(ctx, ids)
|
||||
}
|
||||
}()
|
||||
|
||||
writeDeleteManyResponse(w, r, ids)
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "failed to delete missing files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeDeleteManyResponse(w, r, ids)
|
||||
}
|
||||
}
|
||||
|
||||
var _ model.ResourceRepository = &missingRepository{}
|
||||
|
||||
@@ -22,70 +22,71 @@ import (
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
share core.Share
|
||||
playlists core.Playlists
|
||||
insights metrics.Insights
|
||||
libs core.Library
|
||||
ds model.DataStore
|
||||
share core.Share
|
||||
playlists core.Playlists
|
||||
insights metrics.Insights
|
||||
libs core.Library
|
||||
maintenance core.Maintenance
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService}
|
||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
}
|
||||
|
||||
func (n *Router) routes() http.Handler {
|
||||
func (api *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Public
|
||||
n.RX(r, "/translation", newTranslationRepository, false)
|
||||
api.RX(r, "/translation", newTranslationRepository, false)
|
||||
|
||||
// Protected
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.Authenticator(n.ds))
|
||||
r.Use(server.Authenticator(api.ds))
|
||||
r.Use(server.JWTRefresher)
|
||||
r.Use(server.UpdateLastAccessMiddleware(n.ds))
|
||||
n.R(r, "/user", model.User{}, true)
|
||||
n.R(r, "/song", model.MediaFile{}, false)
|
||||
n.R(r, "/album", model.Album{}, false)
|
||||
n.R(r, "/artist", model.Artist{}, false)
|
||||
n.R(r, "/genre", model.Genre{}, false)
|
||||
n.R(r, "/player", model.Player{}, true)
|
||||
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
n.R(r, "/radio", model.Radio{}, true)
|
||||
n.R(r, "/tag", model.Tag{}, true)
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
api.R(r, "/user", model.User{}, true)
|
||||
api.R(r, "/song", model.MediaFile{}, false)
|
||||
api.R(r, "/album", model.Album{}, false)
|
||||
api.R(r, "/artist", model.Artist{}, false)
|
||||
api.R(r, "/genre", model.Genre{}, false)
|
||||
api.R(r, "/player", model.Player{}, true)
|
||||
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
api.R(r, "/radio", model.Radio{}, true)
|
||||
api.R(r, "/tag", model.Tag{}, true)
|
||||
if conf.Server.EnableSharing {
|
||||
n.RX(r, "/share", n.share.NewRepository, true)
|
||||
api.RX(r, "/share", api.share.NewRepository, true)
|
||||
}
|
||||
|
||||
n.addPlaylistRoute(r)
|
||||
n.addPlaylistTrackRoute(r)
|
||||
n.addSongPlaylistsRoute(r)
|
||||
n.addQueueRoute(r)
|
||||
n.addMissingFilesRoute(r)
|
||||
n.addKeepAliveRoute(r)
|
||||
n.addInsightsRoute(r)
|
||||
api.addPlaylistRoute(r)
|
||||
api.addPlaylistTrackRoute(r)
|
||||
api.addSongPlaylistsRoute(r)
|
||||
api.addQueueRoute(r)
|
||||
api.addMissingFilesRoute(r)
|
||||
api.addKeepAliveRoute(r)
|
||||
api.addInsightsRoute(r)
|
||||
|
||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||
n.addInspectRoute(r)
|
||||
n.addConfigRoute(r)
|
||||
n.addUserLibraryRoute(r)
|
||||
n.RX(r, "/library", n.libs.NewRepository, true)
|
||||
api.addInspectRoute(r)
|
||||
api.addConfigRoute(r)
|
||||
api.addUserLibraryRoute(r)
|
||||
api.RX(r, "/library", api.libs.NewRepository, true)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
|
||||
func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return n.ds.Resource(ctx, model)
|
||||
return api.ds.Resource(ctx, model)
|
||||
}
|
||||
n.RX(r, pathPrefix, constructor, persistable)
|
||||
api.RX(r, pathPrefix, constructor, persistable)
|
||||
}
|
||||
|
||||
func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
r.Route(pathPrefix, func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
if persistable {
|
||||
@@ -102,9 +103,9 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addPlaylistRoute(r chi.Router) {
|
||||
func (api *Router) addPlaylistRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return n.ds.Resource(ctx, model.Playlist{})
|
||||
return api.ds.Resource(ctx, model.Playlist{})
|
||||
}
|
||||
|
||||
r.Route("/playlist", func(r chi.Router) {
|
||||
@@ -114,7 +115,7 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
|
||||
rest.Post(constructor)(w, r)
|
||||
return
|
||||
}
|
||||
createPlaylistFromM3U(n.playlists)(w, r)
|
||||
createPlaylistFromM3U(api.playlists)(w, r)
|
||||
})
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
@@ -126,55 +127,53 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
func (api *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylist(n.ds)(w, r)
|
||||
getPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(n.ds)(w, r)
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
addToPlaylist(n.ds)(w, r)
|
||||
addToPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylistTrack(n.ds)(w, r)
|
||||
getPlaylistTrack(api.ds)(w, r)
|
||||
})
|
||||
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
reorderItem(n.ds)(w, r)
|
||||
reorderItem(api.ds)(w, r)
|
||||
})
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(n.ds)(w, r)
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
func (api *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
|
||||
getSongPlaylists(n.ds)(w, r)
|
||||
getSongPlaylists(api.ds)(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addQueueRoute(r chi.Router) {
|
||||
func (api *Router) addQueueRoute(r chi.Router) {
|
||||
r.Route("/queue", func(r chi.Router) {
|
||||
r.Get("/", getQueue(n.ds))
|
||||
r.Post("/", saveQueue(n.ds))
|
||||
r.Put("/", updateQueue(n.ds))
|
||||
r.Delete("/", clearQueue(n.ds))
|
||||
r.Get("/", getQueue(api.ds))
|
||||
r.Post("/", saveQueue(api.ds))
|
||||
r.Put("/", updateQueue(api.ds))
|
||||
r.Delete("/", clearQueue(api.ds))
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
||||
func (api *Router) addMissingFilesRoute(r chi.Router) {
|
||||
r.Route("/missing", func(r chi.Router) {
|
||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteMissingFiles(n.ds, w, r)
|
||||
})
|
||||
api.RX(r, "/", newMissingRepository(api.ds), false)
|
||||
r.Delete("/", deleteMissingFiles(api.maintenance))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -198,7 +197,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addInspectRoute(r chi.Router) {
|
||||
func (api *Router) addInspectRoute(r chi.Router) {
|
||||
if conf.Server.Inspect.Enabled {
|
||||
r.Group(func(r chi.Router) {
|
||||
if conf.Server.Inspect.MaxRequests > 0 {
|
||||
@@ -207,26 +206,26 @@ func (n *Router) addInspectRoute(r chi.Router) {
|
||||
conf.Server.Inspect.BacklogTimeout)
|
||||
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
|
||||
}
|
||||
r.Get("/inspect", inspect(n.ds))
|
||||
r.Get("/inspect", inspect(api.ds))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addConfigRoute(r chi.Router) {
|
||||
func (api *Router) addConfigRoute(r chi.Router) {
|
||||
if conf.Server.DevUIShowConfig {
|
||||
r.Get("/config/*", getConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addKeepAliveRoute(r chi.Router) {
|
||||
func (api *Router) addKeepAliveRoute(r chi.Router) {
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addInsightsRoute(r chi.Router) {
|
||||
func (api *Router) addInsightsRoute(r chi.Router) {
|
||||
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
last, success := n.insights.LastRun(r.Context())
|
||||
last, success := api.insights.LastRun(r.Context())
|
||||
if conf.Server.EnableInsightsCollector {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
||||
} else {
|
||||
|
||||
@@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
mfRepo.SetData(testSongs)
|
||||
|
||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
@@ -127,6 +127,7 @@ const Player = () => {
|
||||
/>
|
||||
),
|
||||
locale: locale(translate),
|
||||
sortableOptions: { delay: 200, delayOnTouchOnly: true },
|
||||
}),
|
||||
[gainInfo, isDesktop, playerTheme, translate, playerState.mode],
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
},
|
||||
background: {
|
||||
default: '#f0f2f5',
|
||||
paper: 'inherit',
|
||||
paper: bLight['500'],
|
||||
},
|
||||
text: {
|
||||
secondary: '#232323',
|
||||
|
||||
@@ -95,7 +95,7 @@ export const formatFullDate = (date, locale) => {
|
||||
return new Date(date).toLocaleDateString(locale, options)
|
||||
}
|
||||
|
||||
export const formatNumber = (value) => {
|
||||
export const formatNumber = (value, locale) => {
|
||||
if (value === null || value === undefined) return '0'
|
||||
return value.toLocaleString()
|
||||
return value.toLocaleString(locale)
|
||||
}
|
||||
|
||||
@@ -121,35 +121,35 @@ describe('formatDuration2', () => {
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('handles null and undefined values', () => {
|
||||
expect(formatNumber(null)).toEqual('0')
|
||||
expect(formatNumber(undefined)).toEqual('0')
|
||||
expect(formatNumber(null, 'en-CA')).toEqual('0')
|
||||
expect(formatNumber(undefined, 'en-CA')).toEqual('0')
|
||||
})
|
||||
|
||||
it('formats integers', () => {
|
||||
expect(formatNumber(0)).toEqual('0')
|
||||
expect(formatNumber(1)).toEqual('1')
|
||||
expect(formatNumber(123)).toEqual('123')
|
||||
expect(formatNumber(1000)).toEqual('1,000')
|
||||
expect(formatNumber(1234567)).toEqual('1,234,567')
|
||||
expect(formatNumber(0, 'en-CA')).toEqual('0')
|
||||
expect(formatNumber(1, 'en-CA')).toEqual('1')
|
||||
expect(formatNumber(123, 'en-CA')).toEqual('123')
|
||||
expect(formatNumber(1000, 'en-CA')).toEqual('1,000')
|
||||
expect(formatNumber(1234567, 'en-CA')).toEqual('1,234,567')
|
||||
})
|
||||
|
||||
it('formats decimal numbers', () => {
|
||||
expect(formatNumber(123.45)).toEqual('123.45')
|
||||
expect(formatNumber(1234.567)).toEqual('1,234.567')
|
||||
expect(formatNumber(123.45, 'en-CA')).toEqual('123.45')
|
||||
expect(formatNumber(1234.567, 'en-CA')).toEqual('1,234.567')
|
||||
})
|
||||
|
||||
it('formats negative numbers', () => {
|
||||
expect(formatNumber(-123)).toEqual('-123')
|
||||
expect(formatNumber(-1234)).toEqual('-1,234')
|
||||
expect(formatNumber(-123.45)).toEqual('-123.45')
|
||||
expect(formatNumber(-123, 'en-CA')).toEqual('-123')
|
||||
expect(formatNumber(-1234, 'en-CA')).toEqual('-1,234')
|
||||
expect(formatNumber(-123.45, 'en-CA')).toEqual('-123.45')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFullDate', () => {
|
||||
it('format dates', () => {
|
||||
expect(formatFullDate('2011', 'en-US')).toEqual('2011')
|
||||
expect(formatFullDate('2011-06', 'en-US')).toEqual('Jun 2011')
|
||||
expect(formatFullDate('1985-01-01', 'en-US')).toEqual('Jan 1, 1985')
|
||||
expect(formatFullDate('2011', 'en-CA')).toEqual('2011')
|
||||
expect(formatFullDate('2011-06', 'en-CA')).toEqual('Jun 2011')
|
||||
expect(formatFullDate('1985-01-01', 'en-CA')).toEqual('Jan 1, 1985')
|
||||
expect(formatFullDate('199704')).toEqual('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package str
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var utf8ToAscii = func() *strings.Replacer {
|
||||
@@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string {
|
||||
}
|
||||
return list[0]
|
||||
}
|
||||
|
||||
// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated.
|
||||
// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual
|
||||
// string content will be truncated to fit within the maxRunes limit including the suffix.
|
||||
func TruncateRunes(s string, maxRunes int, suffix string) string {
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
|
||||
suffixRunes := utf8.RuneCountInString(suffix)
|
||||
truncateAt := maxRunes - suffixRunes
|
||||
if truncateAt < 0 {
|
||||
truncateAt = 0
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
if truncateAt >= len(runes) {
|
||||
return s + suffix
|
||||
}
|
||||
|
||||
return string(runes[:truncateAt]) + suffix
|
||||
}
|
||||
|
||||
@@ -31,6 +31,72 @@ var _ = Describe("String Utils", func() {
|
||||
Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TruncateRunes", func() {
|
||||
It("returns string unchanged if under max runes", func() {
|
||||
Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("returns string unchanged if exactly at max runes", func() {
|
||||
Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("truncates and adds suffix when over max runes", func() {
|
||||
Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello..."))
|
||||
})
|
||||
|
||||
It("handles unicode characters correctly", func() {
|
||||
// 6 emoji characters, maxRunes=5, suffix="..." (3 runes)
|
||||
// So content gets 5-3=2 runes
|
||||
Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁..."))
|
||||
})
|
||||
|
||||
It("handles multi-byte UTF-8 characters", func() {
|
||||
// Characters like é are single runes
|
||||
Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca..."))
|
||||
})
|
||||
|
||||
It("works with empty suffix", func() {
|
||||
Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("accounts for suffix length in truncation", func() {
|
||||
// maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content
|
||||
result := str.TruncateRunes("hello world this is long", 10, "...")
|
||||
Expect(result).To(Equal("hello w..."))
|
||||
// Verify total rune count is <= maxRunes
|
||||
runeCount := len([]rune(result))
|
||||
Expect(runeCount).To(BeNumerically("<=", 10))
|
||||
})
|
||||
|
||||
It("handles very long suffix gracefully", func() {
|
||||
// If suffix is longer than maxRunes, we still add it
|
||||
// but the content will be truncated to 0
|
||||
result := str.TruncateRunes("hello world", 5, "... (truncated)")
|
||||
// Result will be just the suffix (since truncateAt=0)
|
||||
Expect(result).To(Equal("... (truncated)"))
|
||||
})
|
||||
|
||||
It("handles empty string", func() {
|
||||
Expect(str.TruncateRunes("", 10, "...")).To(Equal(""))
|
||||
})
|
||||
|
||||
It("uses custom suffix", func() {
|
||||
// maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes
|
||||
// "hello world" is 11 runes exactly, so we need a longer string
|
||||
Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]"))
|
||||
})
|
||||
|
||||
DescribeTable("truncates at rune boundaries (not byte boundaries)",
|
||||
func(input string, maxRunes int, suffix string, expected string) {
|
||||
Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected))
|
||||
},
|
||||
Entry("ASCII", "abcdefghij", 5, "...", "ab..."),
|
||||
Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."),
|
||||
Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"),
|
||||
Entry("Japanese", "こんにちは世界", 3, "…", "こん…"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
var testPaths = []string{
|
||||
|
||||
Reference in New Issue
Block a user