Compare commits

...

19 Commits

Author SHA1 Message Date
Deluan Quintão
f4d0fa836e
Merge 52d8cf4f760fd0f87e5b239a99ea1ea72fec9e90 into ff583970f099df7c2bec649aa6a928756387dc1b 2025-11-08 21:20:52 -05:00
Deluan
ff583970f0 chore(deps): update golang.org/x/sync to v0.18.0 and golang.org/x/sys to v0.38.0
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-08 21:05:29 -05:00
Deluan
38ca65726a chore(deps): update wazero to version 1.10.0 and clean up go.mod
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-08 21:04:20 -05:00
Deluan Quintão
5ce6e16d96
fix: album statistics not updating after deleting missing files (#4668)
* feat: add album refresh functionality after deleting missing files

Implemented RefreshAlbums method in AlbumRepository to recalculate album attributes (size, duration, song count) from their constituent media files. This method processes albums in batches to maintain efficiency with large datasets.

Added integration in deleteMissingFiles to automatically refresh affected albums in the background after deleting missing media files, ensuring album statistics remain accurate. Includes comprehensive test coverage for various scenarios including single/multiple albums, empty batches, and large batch processing.

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

* refactor: extract missing files deletion into reusable service layer

Extracted inline deletion logic from server/nativeapi/missing.go into a new core.MissingFiles service interface and implementation. This provides better separation of concerns and testability.

The MissingFiles service handles:
- Deletion of specific or all missing files via transaction
- Garbage collection after deletion
- Extraction of affected album IDs from missing files
- Background refresh of artist and album statistics

The deleteMissingFiles HTTP handler now simply delegates to the service, removing 70+ lines of inline logic. All deletion, transaction, and stat refresh logic is now centralized in core/missing_files.go.

Updated dependency injection to provide MissingFiles service to the native API router. Renamed receiver variable from 'n' to 'api' throughout native_api.go for consistency.

* refactor: consolidate maintenance operations into unified service

Consolidate MissingFiles and RefreshAlbums functionality into a new Maintenance service. This refactoring:
- Creates core.Maintenance interface combining DeleteMissingFiles, DeleteAllMissingFiles, and RefreshAlbums methods
- Moves RefreshAlbums logic from AlbumRepository persistence layer to core Maintenance service
- Removes MissingFiles interface and moves its implementation to maintenanceService
- Updates all references in wire providers, native API router, and handlers
- Removes RefreshAlbums interface method from AlbumRepository model
- Improves separation of concerns by centralizing maintenance operations in the core domain

This change provides a cleaner API and better organization of maintenance-related database operations.

* refactor: remove MissingFiles interface and update references

Remove obsolete MissingFiles interface and its references:
- Delete core/missing_files.go and core/missing_files_test.go
- Remove RefreshAlbums method from AlbumRepository interface and implementation
- Remove RefreshAlbums tests from AlbumRepository test suite
- Update wire providers to use NewMaintenance instead of NewMissingFiles
- Update native API router to use Maintenance service
- Update missing.go handler to use Maintenance interface

All functionality is now consolidated in the core.Maintenance service.

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

* refactor: rename RefreshAlbums to refreshAlbums and update related calls

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

* refactor: optimize album refresh logic and improve test coverage

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

* refactor: simplify logging setup in tests with reusable LogHook function

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

* refactor: add synchronization to logger and maintenance service for thread safety

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-08 20:11:00 -05:00
Deluan Quintão
69527085db
fix(ui): resolve transparent dropdown background in Ligera theme (#4665)
Fixed the multi-library selector dropdown background in the Ligera theme by changing the palette.background.paper value from 'inherit' to bLight['500'] ('#ffffff'). This ensures the dropdown has a solid white background that properly overlays content, making the library selection options clearly readable.

Closes #4502
2025-11-08 12:47:02 -05:00
Nagi
9bb933c0d6
fix(ui): fix Playlist Italian translation(#4642)
In Italian, we usually use "Playlist" rather than "Scalette/a". "Scalette/a" refers to other functions or objects.
2025-11-07 18:41:23 -05:00
Deluan Quintão
6f4fa76772
fix(ui): update Galician, Dutch, Thai translations from POEditor (#4416)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-11-07 18:20:39 -05:00
Deluan
9621a40f29 feat(ui): add Vietnamese localization for the application 2025-11-07 18:13:46 -05:00
DDinghoya
df95dffa74
fix(ui): update ko.json (#4443)
* Update ko.json

* Update ko.json

Removed remove one of the entrie as below

"shuffleAll": "모두 셔플"

* Update ko.json

* Update ko.json

* Update ko.json

* Update ko.json

* Update ko.json
2025-11-07 18:10:38 -05:00
York
a59b59192a
fix(ui): update zh-Hant.json (#4454)
* Update zh-Hant.json

Updated and optimized Traditional Chinese translation.

* Update zh-Hant.json

Updated and optimized Traditional Chinese translation.

* Update zh-Hant.json

Updated and optimized Traditional Chinese translation.
2025-11-07 18:06:41 -05:00
Deluan
52d8cf4f76 docs: add FindByPaths comment
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 17:34:03 -05:00
Deluan
15de641bea refactor: consolidate path resolution logic
Collapse resolveRelativePath and resolveAbsolutePath into a unified
resolvePath function, extracting common library matching logic into a
new findInLibraries helper method.

This eliminates duplicate code (~20 lines) while maintaining clear
separation of concerns: resolvePath handles path normalization
(relative vs absolute), and findInLibraries handles library matching.

Update tests to call resolvePath directly with appropriate parameters,
maintaining full test coverage for both absolute and relative path
scenarios.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 17:28:26 -05:00
Deluan
b4b618eb0c refactor: remove unnecessary path validation fallback
Remove validatePathInLibrary function and its fallback logic in
resolveRelativePath. The library matcher should always find the correct
library, including the playlist's own library. If this fails, we now
return an invalid resolution instead of attempting a fallback validation.

This simplifies the code by removing redundant validation logic that
was masking test setup issues. Also fixes test mock configuration to
properly set up library paths that match folder LibraryPath values.
2025-11-07 17:28:24 -05:00
Deluan
f8c430586d refactor
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 16:54:07 -05:00
Deluan
b0cc0a2f40 fix: improve path resolution for cross-library playlists and enhance error handling
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 16:41:26 -05:00
Deluan
a875606c28 fix: lint
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 14:51:02 -05:00
Deluan
7cd4a482c2 refactor: simplify mocks
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 14:41:10 -05:00
Deluan
5c4df0912e fix: enhance handling of library-qualified paths and improve cross-library playlist support
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 13:11:19 -05:00
Deluan
9a50079978 test: refactor tests isolation
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-07 08:46:59 -05:00
26 changed files with 3154 additions and 878 deletions

View File

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

226
core/maintenance.go Normal file
View File

@ -0,0 +1,226 @@
package core
import (
"context"
"fmt"
"slices"
"sync"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
)
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
}
type maintenanceService struct {
ds model.DataStore
wg sync.WaitGroup
}
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 chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) {
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
s.wg.Add(1)
go func() {
defer s.wg.Done()
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))
}
}
}()
}
// Wait waits for all background goroutines to complete.
// WARNING: This method is ONLY for testing. Never call this in production code.
// Calling Wait() in production will block until ALL background operations complete
// and may cause race conditions with new operations starting.
func (s *maintenanceService) wait() {
s.wg.Wait()
}

382
core/maintenance_test.go Normal file
View File

@ -0,0 +1,382 @@
package core
import (
"context"
"errors"
"sync"
"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"
"github.com/sirupsen/logrus"
)
var _ = Describe("Maintenance", func() {
var ds *extendedDataStore
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 = createTestDataStore()
mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo)
service = NewMaintenance(ds)
})
Describe("DeleteMissingFiles", func() {
Context("with specific IDs", func() {
It("deletes specific missing files and runs GC", 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"}))
Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion")
})
It("triggers artist stats refresh and album refresh after deletion", func() {
artistRepo := ds.MockedArtist.(*extendedArtistRepo)
// Setup: mock missing files with albums
albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
albumRepo.SetData(model.Albums{
{ID: "album1", Name: "Test Album", SongCount: 5},
})
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
{ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
{ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200},
})
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
Expect(err).ToNot(HaveOccurred())
// Wait for background goroutines to complete
service.(*maintenanceService).wait()
// RefreshStats should be called
Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
// Album should be updated with new calculated values
Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data")
})
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},
})
// Set GC to return error
ds.gcError = errors.New("gc failed")
err := service.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 and runs GC", 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())
Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion")
})
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())
})
})
Describe("Album refresh logic", func() {
var albumRepo *extendedAlbumRepo
BeforeEach(func() {
albumRepo = ds.MockedAlbum.(*extendedAlbumRepo)
})
Context("when album has no tracks after deletion", func() {
It("skips the album without updating it", func() {
// Setup album with no remaining tracks
albumRepo.SetData(model.Albums{
{ID: "album1", Name: "Empty Album", SongCount: 1},
})
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
})
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
Expect(err).ToNot(HaveOccurred())
// Wait for background goroutines to complete
service.(*maintenanceService).wait()
// Album should NOT be updated because it has no tracks left
Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated")
})
})
Context("when Put fails for one album", func() {
It("continues processing other albums", func() {
albumRepo.SetData(model.Albums{
{ID: "album1", Name: "Album 1"},
{ID: "album2", Name: "Album 2"},
})
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
{ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
{ID: "mf3", AlbumID: "album2", Missing: true},
{ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200},
})
// Make Put fail on first call but succeed on subsequent calls
albumRepo.putError = errors.New("put failed")
albumRepo.failOnce = true
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"})
// Should not fail even if one album's Put fails
Expect(err).ToNot(HaveOccurred())
// Wait for background goroutines to complete
service.(*maintenanceService).wait()
// Put should have been called multiple times
Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted")
})
})
Context("when media file loading fails", func() {
It("logs warning but continues when tracking affected albums fails", func() {
// Set up log capturing
hook, cleanup := tests.LogHook()
defer cleanup()
albumRepo.SetData(model.Albums{
{ID: "album1", Name: "Album 1"},
})
mfRepo.SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "album1", Missing: true},
})
// Make GetAll fail when loading media files
mfRepo.SetError(true)
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
// Deletion should succeed despite the tracking error
Expect(err).ToNot(HaveOccurred())
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
// Verify the warning was logged
Expect(hook.LastEntry()).ToNot(BeNil())
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh"))
})
})
})
})
// Test helper to create a mock DataStore with controllable behavior
func createTestDataStore() *extendedDataStore {
// Create extended datastore with GC tracking
ds := &extendedDataStore{
MockDataStore: &tests.MockDataStore{},
}
// Create extended album repo with Put tracking
albumRepo := &extendedAlbumRepo{
MockAlbumRepo: tests.CreateMockAlbumRepo(),
}
ds.MockedAlbum = albumRepo
// Create extended artist repo with RefreshStats tracking
artistRepo := &extendedArtistRepo{
MockArtistRepo: tests.CreateMockArtistRepo(),
}
ds.MockedArtist = artistRepo
// Create extended media file repo with DeleteMissing support
mfRepo := &extendedMediaFileRepo{
MockMediaFileRepo: tests.CreateMockMediaFileRepo(),
}
ds.MockedMediaFile = mfRepo
return ds
}
// 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
}
// Extension of MockAlbumRepo to track Put calls
type extendedAlbumRepo struct {
*tests.MockAlbumRepo
mu sync.RWMutex
putCallCount int
lastPutData *model.Album
putError error
failOnce bool
}
func (m *extendedAlbumRepo) Put(album *model.Album) error {
m.mu.Lock()
m.putCallCount++
m.lastPutData = album
// Handle failOnce behavior
var err error
if m.putError != nil {
if m.failOnce {
err = m.putError
m.putError = nil // Clear error after first failure
m.mu.Unlock()
return err
}
err = m.putError
m.mu.Unlock()
return err
}
m.mu.Unlock()
return m.MockAlbumRepo.Put(album)
}
func (m *extendedAlbumRepo) GetPutCallCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return m.putCallCount
}
// Extension of MockArtistRepo to track RefreshStats calls
type extendedArtistRepo struct {
*tests.MockArtistRepo
mu sync.RWMutex
refreshStatsCalled bool
refreshStatsError error
}
func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
m.mu.Lock()
m.refreshStatsCalled = true
err := m.refreshStatsError
m.mu.Unlock()
if err != nil {
return 0, err
}
return m.MockArtistRepo.RefreshStats(allArtists)
}
func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.refreshStatsCalled
}
// Extension of MockDataStore to track GC calls
type extendedDataStore struct {
*tests.MockDataStore
gcCalled bool
gcError error
}
func (ds *extendedDataStore) GC(ctx context.Context) error {
ds.gcCalled = true
if ds.gcError != nil {
return ds.gcError
}
return ds.MockDataStore.GC(ctx)
}

View File

@ -10,7 +10,6 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
@ -196,22 +195,31 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
}
filteredLines = append(filteredLines, line)
}
paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
if err != nil {
log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
continue
}
found, err := mediaFileRepository.FindByPaths(paths)
found, err := mediaFileRepository.FindByPaths(resolvedPaths)
if err != nil {
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue
}
// Build lookup map with library-qualified keys
existing := make(map[string]int, len(found))
for idx := range found {
existing[normalizePathForComparison(found[idx].Path)] = idx
// Key format: "libraryID:path" (normalized)
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, normalizePathForComparison(found[idx].Path))
existing[key] = idx
}
for _, path := range paths {
idx, ok := existing[normalizePathForComparison(path)]
for _, path := range resolvedPaths {
// Parse the library-qualified path
parts := strings.SplitN(path, ":", 2)
// Path is already qualified: "libraryID:path"
normalizedPath := parts[0] + ":" + normalizePathForComparison(parts[1])
idx, ok := existing[normalizedPath]
if ok {
mfs = append(mfs, found[idx])
} else {
@ -239,131 +247,146 @@ func normalizePathForComparison(path string) string {
type pathResolution struct {
absolutePath string
libraryPath string
libraryID int
valid bool
}
// toRelativePath converts the absolute path to a library-relative path.
func (r pathResolution) toRelativePath() (string, error) {
// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
// Format: "libraryID:relativePath" with forward slashes for path separators.
func (r pathResolution) ToQualifiedString() (string, error) {
if !r.valid {
return "", fmt.Errorf("invalid path resolution")
}
return filepath.Rel(r.libraryPath, r.absolutePath)
relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
if err != nil {
return "", err
}
// Convert path separators to forward slashes
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
}
// newValidResolution creates a valid path resolution.
func newValidResolution(absolutePath, libraryPath string) pathResolution {
// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
type libraryMatcher struct {
libraries model.Libraries
cleanedPaths []string
}
// findLibraryForPath finds which library contains the given absolute path.
// Returns library ID and path, or 0 and empty string if not found.
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
// Check sorted libraries (longest path first) to find the best match
for i, cleanLibPath := range lm.cleanedPaths {
// Check if absolutePath is under this library path
if strings.HasPrefix(absolutePath, cleanLibPath) {
// Ensure it's a proper path boundary (not just a prefix)
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
return lm.libraries[i].ID, cleanLibPath
}
}
}
return 0, ""
}
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
// This ensures correct matching when library paths are prefixes of each other.
// Example: /music-classical must be checked before /music
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
// Sort libraries by path length (descending) to ensure longest paths match first.
slices.SortFunc(libs, func(i, j model.Library) int {
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
})
// Pre-clean all library paths once for efficient matching
cleanedPaths := make([]string, len(libs))
for i, lib := range libs {
cleanedPaths[i] = filepath.Clean(lib.Path)
}
return &libraryMatcher{
libraries: libs,
cleanedPaths: cleanedPaths,
}
}
// pathResolver handles path resolution logic for playlist imports.
type pathResolver struct {
matcher *libraryMatcher
}
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
libs, err := ds.Library(ctx).GetAll()
if err != nil {
return nil, err
}
matcher := newLibraryMatcher(libs)
return &pathResolver{matcher: matcher}, nil
}
// resolvePath determines the absolute path and library path for a playlist entry.
// For absolute paths, it uses them directly.
// For relative paths, it resolves them relative to the playlist's folder location.
// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
//
// resolves to /music/songs/abc.mp3
func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
var absolutePath string
if folder != nil && !filepath.IsAbs(line) {
// Resolve relative path to absolute path based on playlist location
absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
} else {
// Use absolute path directly after cleaning
absolutePath = filepath.Clean(line)
}
return r.findInLibraries(absolutePath)
}
// findInLibraries matches an absolute path against all known libraries and returns
// a pathResolution with the library information. Returns an invalid resolution if
// the path is not found in any library.
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
libID, libPath := r.matcher.findLibraryForPath(absolutePath)
if libID == 0 {
return pathResolution{valid: false}
}
return pathResolution{
absolutePath: absolutePath,
libraryPath: libraryPath,
libraryPath: libPath,
libraryID: libID,
valid: true,
}
}
// normalizePaths converts playlist file paths to library-relative paths.
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
// For relative paths, it resolves them to absolute paths first, then determines which
// library they belong to. This allows playlists to reference files across library boundaries.
func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
libRegex, err := s.compileLibraryPaths(ctx)
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
resolver, err := newPathResolver(ctx, s.ds)
if err != nil {
return nil, err
}
res := make([]string, 0, len(lines))
results := make([]string, 0, len(lines))
for idx, line := range lines {
resolution := s.resolvePath(line, folder, libRegex)
resolution := resolver.resolvePath(line, folder)
if !resolution.valid {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
continue
}
relativePath, err := resolution.toRelativePath()
qualifiedPath, err := resolution.ToQualifiedString()
if err != nil {
log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line,
log.Debug(ctx, "Error getting library-qualified path", "path", line,
"libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
continue
}
res = append(res, relativePath)
}
return slice.Map(res, filepath.ToSlash), nil
results = append(results, qualifiedPath)
}
// resolvePath determines the absolute path and library path for a playlist entry.
func (s *playlists) resolvePath(line string, folder *model.Folder, libRegex *regexp.Regexp) pathResolution {
if folder != nil && !filepath.IsAbs(line) {
return s.resolveRelativePath(line, folder, libRegex)
}
return s.resolveAbsolutePath(line, libRegex)
}
// resolveRelativePath handles relative paths by converting them to absolute paths
// and finding their library location. This enables cross-library playlist references.
func (s *playlists) resolveRelativePath(line string, folder *model.Folder, libRegex *regexp.Regexp) pathResolution {
// Step 1: Resolve relative path to absolute path based on playlist location
// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
// resolves to /music/songs/abc.mp3
absolutePath := filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
// Step 2: Determine which library this absolute path belongs to using regex matching
if libPath := libRegex.FindString(absolutePath); libPath != "" {
return newValidResolution(absolutePath, libPath)
}
// Fallback: Check if it's in the playlist's own library
return s.validatePathInLibrary(absolutePath, folder.LibraryPath)
}
// resolveAbsolutePath handles absolute paths by matching them against library paths.
func (s *playlists) resolveAbsolutePath(line string, libRegex *regexp.Regexp) pathResolution {
cleanPath := filepath.Clean(line)
libPath := libRegex.FindString(cleanPath)
if libPath == "" {
return pathResolution{valid: false}
}
return newValidResolution(cleanPath, libPath)
}
// validatePathInLibrary verifies that an absolute path belongs to the specified library.
// It rejects paths that escape the library using ".." segments.
func (s *playlists) validatePathInLibrary(absolutePath, libraryPath string) pathResolution {
rel, err := filepath.Rel(libraryPath, absolutePath)
if err != nil || strings.HasPrefix(rel, "..") {
return pathResolution{valid: false}
}
return newValidResolution(absolutePath, libraryPath)
}
func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
libs, err := s.ds.Library(ctx).GetAll()
if err != nil {
return nil, err
}
// Sort libraries by path length (descending) to ensure longest paths match first.
// This prevents shorter paths that are prefixes from incorrectly matching.
// Example: /music-classical must be checked before /music
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
slices.SortFunc(libs, func(i, j model.Library) int {
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
})
// Create regex patterns for each library path
patterns := make([]string, len(libs))
for i, lib := range libs {
cleanPath := filepath.Clean(lib.Path)
escapedPath := regexp.QuoteMeta(cleanPath)
patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
}
// Combine all patterns into a single regex - order matters due to alternation
combinedPattern := strings.Join(patterns, "|")
re, err := regexp.Compile(combinedPattern)
if err != nil {
return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
}
return re, nil
return results, nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {

View File

@ -7,12 +7,12 @@ import (
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"golang.org/x/text/unicode/norm"
)
var _ = Describe("compileLibraryPaths", func() {
var _ = Describe("libraryMatcher", func() {
var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo
var ps *playlists
ctx := context.Background()
BeforeEach(func() {
@ -20,9 +20,15 @@ var _ = Describe("compileLibraryPaths", func() {
ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo,
}
ps = &playlists{ds: ds}
})
// Helper function to create a libraryMatcher from the mock datastore
createMatcher := func(ds model.DataStore) *libraryMatcher {
libs, err := ds.Library(ctx).GetAll()
Expect(err).ToNot(HaveOccurred())
return newLibraryMatcher(libs)
}
Describe("Longest library path matching", func() {
It("matches the longest library path when multiple libraries share a prefix", func() {
// Setup libraries with prefix conflicts
@ -32,25 +38,24 @@ var _ = Describe("compileLibraryPaths", func() {
{ID: 3, Path: "/music-classical/opera"},
})
libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
matcher := createMatcher(ds)
// Test that longest path matches first
// Note: The regex pattern ^path(?:/|$) will match the path plus the trailing /
// Test that longest path matches first and returns correct library ID
testCases := []struct {
path string
expected string
expectedLibID int
expectedLibPath string
}{
{"/music-classical/opera/track.mp3", "/music-classical/opera/"},
{"/music-classical/track.mp3", "/music-classical/"},
{"/music/track.mp3", "/music/"},
{"/music-classical/opera/", "/music-classical/opera/"}, // Trailing slash
{"/music-classical/opera", "/music-classical/opera"}, // Exact match (no trailing /)
{"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
{"/music-classical/track.mp3", 2, "/music-classical"},
{"/music/track.mp3", 1, "/music"},
{"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
}
for _, tc := range testCases {
matched := libRegex.FindString(tc.path)
Expect(matched).To(Equal(tc.expected), "Path %s should match %s, but got %s", tc.path, tc.expected, matched)
libID, libPath := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
}
})
@ -60,16 +65,17 @@ var _ = Describe("compileLibraryPaths", func() {
{ID: 2, Path: "/home/user/music-backup"},
})
libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
matcher := createMatcher(ds)
// Test that music-backup library is matched correctly
matched := libRegex.FindString("/home/user/music-backup/track.mp3")
Expect(matched).To(Equal("/home/user/music-backup/"))
libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
Expect(libID).To(Equal(2))
Expect(libPath).To(Equal("/home/user/music-backup"))
// Test that music library is still matched correctly
matched = libRegex.FindString("/home/user/music/track.mp3")
Expect(matched).To(Equal("/home/user/music/"))
libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/home/user/music"))
})
It("matches path that is exactly the library root", func() {
@ -78,12 +84,12 @@ var _ = Describe("compileLibraryPaths", func() {
{ID: 2, Path: "/music-classical"},
})
libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
matcher := createMatcher(ds)
// Exact library path should match (no trailing /)
matched := libRegex.FindString("/music-classical")
Expect(matched).To(Equal("/music-classical"))
// Exact library path should match
libID, libPath := matcher.findLibraryForPath("/music-classical")
Expect(libID).To(Equal(2))
Expect(libPath).To(Equal("/music-classical"))
})
It("handles complex nested library structures", func() {
@ -94,22 +100,23 @@ var _ = Describe("compileLibraryPaths", func() {
{ID: 4, Path: "/media/audio/classical/baroque"},
})
libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
matcher := createMatcher(ds)
testCases := []struct {
path string
expected string
expectedLibID int
expectedLibPath string
}{
{"/media/audio/classical/baroque/bach/track.mp3", "/media/audio/classical/baroque/"},
{"/media/audio/classical/mozart/track.mp3", "/media/audio/classical/"},
{"/media/audio/rock/track.mp3", "/media/audio/"},
{"/media/video/movie.mp4", "/media/"},
{"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
{"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
{"/media/audio/rock/track.mp3", 2, "/media/audio"},
{"/media/video/movie.mp4", 1, "/media"},
}
for _, tc := range testCases {
matched := libRegex.FindString(tc.path)
Expect(matched).To(Equal(tc.expected), "Path %s should match %s, but got %s", tc.path, tc.expected, matched)
libID, libPath := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
}
})
})
@ -118,13 +125,13 @@ var _ = Describe("compileLibraryPaths", func() {
It("handles empty library list", func() {
mockLibRepo.SetData([]model.Library{})
libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(libRegex).ToNot(BeNil())
matcher := createMatcher(ds)
Expect(matcher).ToNot(BeNil())
// Should not match anything
matched := libRegex.FindString("/music/track.mp3")
Expect(matched).To(BeEmpty())
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
Expect(libID).To(Equal(0))
Expect(libPath).To(BeEmpty())
})
It("handles single library", func() {
@ -132,55 +139,294 @@ var _ = Describe("compileLibraryPaths", func() {
{ID: 1, Path: "/music"},
})
libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
matcher := createMatcher(ds)
matched := libRegex.FindString("/music/track.mp3")
Expect(matched).To(Equal("/music/"))
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/music"))
})
It("handles libraries with special regex characters", func() {
It("handles libraries with special characters in paths", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music[test]"},
{ID: 2, Path: "/music(backup)"},
})
libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(libRegex).ToNot(BeNil())
matcher := createMatcher(ds)
Expect(matcher).ToNot(BeNil())
// Special characters should be escaped and match literally
matched := libRegex.FindString("/music[test]/track.mp3")
Expect(matched).To(Equal("/music[test]/"))
// Special characters should match literally
libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/music[test]"))
})
})
Describe("Regex pattern validation", func() {
It("ensures regex alternation respects order by testing actual matching behavior", func() {
Describe("Path matching order", func() {
It("ensures longest paths match first", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/a"},
{ID: 2, Path: "/ab"},
{ID: 3, Path: "/abc"},
})
libRegex, err := ps.compileLibraryPaths(ctx)
Expect(err).ToNot(HaveOccurred())
matcher := createMatcher(ds)
// Verify that longer paths match correctly (not cut off by shorter prefix)
// If ordering is wrong, /ab would match before /abc for path "/abc/file"
testCases := []struct {
path string
expected string
expectedLibID int
}{
{"/abc/file.mp3", "/abc/"},
{"/ab/file.mp3", "/ab/"},
{"/a/file.mp3", "/a/"},
{"/abc/file.mp3", 3},
{"/ab/file.mp3", 2},
{"/a/file.mp3", 1},
}
for _, tc := range testCases {
matched := libRegex.FindString(tc.path)
Expect(matched).To(Equal(tc.expected), "Path %s should match %s", tc.path, tc.expected)
libID, _ := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
}
})
})
})
var _ = Describe("pathResolver", func() {
var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo
var resolver *pathResolver
ctx := context.Background()
BeforeEach(func() {
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo,
}
// Setup test libraries
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
{ID: 2, Path: "/music-classical"},
{ID: 3, Path: "/podcasts"},
})
var err error
resolver, err = newPathResolver(ctx, ds)
Expect(err).ToNot(HaveOccurred())
})
Describe("resolvePath", func() {
It("resolves absolute paths", func() {
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("resolves relative paths when folder is provided", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("returns invalid resolution for paths outside any library", func() {
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
Expect(resolution.valid).To(BeFalse())
})
})
Describe("resolvePath", func() {
Context("With absolute paths", func() {
It("resolves path within a library", func() {
resolution := resolver.resolvePath("/music/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/track.mp3"))
})
It("resolves path to the longest matching library", func() {
resolution := resolver.resolvePath("/music-classical/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2))
Expect(resolution.libraryPath).To(Equal("/music-classical"))
})
It("returns invalid resolution for path outside libraries", func() {
resolution := resolver.resolvePath("/videos/movie.mp4", nil)
Expect(resolution.valid).To(BeFalse())
})
It("cleans the path before matching", func() {
resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
})
})
Context("With relative paths", func() {
It("resolves relative path within same library", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../songs/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3"))
})
It("resolves relative path to different library", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// Path goes up and into a different library
resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(3))
Expect(resolution.libraryPath).To(Equal("/podcasts"))
})
It("uses matcher to find correct library for resolved path", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// This relative path resolves to music-classical library
resolution := resolver.resolvePath("../../music-classical/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2))
Expect(resolution.libraryPath).To(Equal("/music-classical"))
})
It("returns invalid for relative paths escaping all libraries", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../../../../etc/passwd", folder)
Expect(resolution.valid).To(BeFalse())
})
})
})
Describe("Cross-library resolution scenarios", func() {
It("handles playlist in library A referencing file in library B", func() {
// Playlist is in /music/playlists
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
// Relative path that goes to /podcasts library
resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library")
Expect(resolution.libraryPath).To(Equal("/podcasts"))
})
It("prefers longer library paths when resolving", func() {
// Ensure /music-classical is matched instead of /music
resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music")
})
})
})
var _ = Describe("pathResolution", func() {
Describe("ToQualifiedString", func() {
It("converts valid resolution to qualified string with forward slashes", func() {
resolution := pathResolution{
absolutePath: "/music/artist/album/track.mp3",
libraryPath: "/music",
libraryID: 1,
valid: true,
}
qualifiedStr, err := resolution.ToQualifiedString()
Expect(err).ToNot(HaveOccurred())
Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3"))
})
It("handles Windows-style paths by converting to forward slashes", func() {
resolution := pathResolution{
absolutePath: "/music/artist/album/track.mp3",
libraryPath: "/music",
libraryID: 2,
valid: true,
}
qualifiedStr, err := resolution.ToQualifiedString()
Expect(err).ToNot(HaveOccurred())
// Should always use forward slashes regardless of OS
Expect(qualifiedStr).To(ContainSubstring("2:"))
Expect(qualifiedStr).ToNot(ContainSubstring("\\"))
})
It("returns error for invalid resolution", func() {
resolution := pathResolution{valid: false}
_, err := resolution.ToQualifiedString()
Expect(err).To(HaveOccurred())
})
})
})
var _ = Describe("normalizePathForComparison", func() {
It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
// Test with NFD (decomposed) input - as would come from macOS filesystem
nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
normalized := normalizePathForComparison(nfdPath)
Expect(normalized).To(Equal("michèle"))
// Test with NFC (composed) input - as would come from Apple Music M3U
nfcPath := "Michèle" // This might be in NFC form
normalizedNfc := normalizePathForComparison(nfcPath)
// Ensure the two paths are not equal in their original forms
Expect(nfdPath).ToNot(Equal(nfcPath))
// Both should normalize to the same result
Expect(normalized).To(Equal(normalizedNfc))
})
It("handles paths with mixed case and Unicode characters", func() {
path := "Artist/Noël Coward/Album/Song.mp3"
normalized := normalizePathForComparison(path)
Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
})
})

View File

@ -1,4 +1,4 @@
package core
package core_test
import (
"context"
@ -9,18 +9,18 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"golang.org/x/text/unicode/norm"
)
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
var ps Playlists
var ps core.Playlists
var mockPlsRepo mockedPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
@ -33,16 +33,16 @@ var _ = Describe("Playlists", func() {
MockedLibrary: mockLibRepo,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
// Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
})
Describe("ImportFile", func() {
var folder *model.Folder
BeforeEach(func() {
ps = NewPlaylists(ds)
ps = core.NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd()
// Set up library with the actual library path that matches the folder
mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
folder = &model.Folder{
ID: "1",
LibraryID: 1,
@ -138,7 +138,7 @@ var _ = Describe("Playlists", func() {
"def.mp3", // This is playlists/def.mp3 relative to plsDir
},
}
ps = NewPlaylists(ds)
ps = core.NewPlaylists(ds)
})
It("handles relative paths that reference files in other libraries", func() {
@ -264,6 +264,71 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!)
})
It("correctly handles identical relative paths from different libraries", func() {
// This tests the bug where two libraries have files at the same relative path
// and only one appears in the playlist
tmpDir := GinkgoT().TempDir()
musicDir := tmpDir + "/music"
classicalDir := tmpDir + "/classical"
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
Expect(os.Mkdir(classicalDir, 0755)).To(Succeed())
Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed())
Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed())
// Create placeholder files so paths resolve correctly
Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
// Both libraries have a file at "album/track.mp3"
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: musicDir},
{ID: 2, Path: classicalDir},
})
// Mock returns files with same relative path but different IDs and library IDs
// Keys use the library-qualified format: "libraryID:path"
ds.MockedMediaFile = &mockedMediaFileRepo{
data: map[string]model.MediaFile{
"1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"},
"2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"},
},
}
// Recreate playlists service to pick up new mock
ps = core.NewPlaylists(ds)
// Create playlist in music library that references both tracks
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
plsFile := musicDir + "/test.m3u"
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
plsFolder := &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: musicDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// Should have BOTH tracks, not just one
Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
// Verify we got tracks from DIFFERENT libraries (the key fix!)
// Collect the library IDs
libIDs := make(map[int]bool)
for _, track := range pls.Tracks {
libIDs[track.LibraryID] = true
}
Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries")
Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1")
Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2")
// Both tracks should have the same relative path
Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
})
})
})
@ -272,7 +337,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = NewPlaylists(ds)
ps = core.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
@ -359,53 +424,6 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
It("handles Unicode normalization when comparing paths", func() {
// Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD
// The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent)
const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a"
// Simulate a database entry with NFD encoding (as stored by macOS filesystem)
nfdPath := norm.NFD.String(pathWithAccents)
repo.data = []string{nfdPath}
// Simulate an Apple Music M3U playlist entry with NFC encoding
nfcPath := norm.NFC.String("/music/" + pathWithAccents)
m3u := strings.Join([]string{
nfcPath,
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences")
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
})
})
Describe("normalizePathForComparison", func() {
It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
// Test with NFD (decomposed) input - as would come from macOS filesystem
nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
normalized := normalizePathForComparison(nfdPath)
Expect(normalized).To(Equal("michèle"))
// Test with NFC (composed) input - as would come from Apple Music M3U
nfcPath := "Michèle" // This might be in NFC form
normalizedNfc := normalizePathForComparison(nfcPath)
// Ensure the two paths are not equal in their original forms
Expect(nfdPath).ToNot(Equal(nfcPath))
// Both should normalize to the same result
Expect(normalized).To(Equal(normalizedNfc))
})
It("handles paths with mixed case and Unicode characters", func() {
path := "Artist/Noël Coward/Album/Song.mp3"
normalized := normalizePathForComparison(path)
Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
})
})
Describe("InPlaylistsPath", func() {
@ -422,27 +440,27 @@ var _ = Describe("Playlists", func() {
It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = ""
Expect(InPlaylistsPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**"
Expect(InPlaylistsPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if folder is in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other/**:playlists/**"
Expect(InPlaylistsPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other"
Expect(InPlaylistsPath(folder)).To(BeFalse())
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
})
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "."
Expect(InPlaylistsPath(folder)).To(BeFalse())
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
folder2 := model.Folder{
LibraryPath: "/music",
@ -450,22 +468,47 @@ var _ = Describe("Playlists", func() {
Name: ".",
}
Expect(InPlaylistsPath(folder2)).To(BeTrue())
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
})
})
})
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths.
// If data map is provided, looks up files by key; otherwise creates them from paths.
type mockedMediaFileRepo struct {
model.MediaFileRepository
data map[string]model.MediaFile
}
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
// If data map provided, look up files
if r.data != nil {
for _, path := range paths {
if mf, ok := r.data[path]; ok {
mfs = append(mfs, mf)
}
}
return mfs, nil
}
// Otherwise, create MediaFiles from paths
for idx, path := range paths {
// Strip library qualifier if present (format: "libraryID:path")
actualPath := path
libraryID := 1
if parts := strings.SplitN(path, ":", 2); len(parts) == 2 {
if id, err := strconv.Atoi(parts[0]); err == nil {
libraryID = id
actualPath = parts[1]
}
}
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
Path: actualPath,
LibraryID: libraryID,
})
}
return mfs, nil
@ -477,13 +520,31 @@ type mockedMediaFileFromListRepo struct {
data []string
}
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, path := range r.data {
for idx, dataPath := range r.data {
for _, requestPath := range paths {
// Strip library qualifier if present (format: "libraryID:path")
actualPath := requestPath
libraryID := 1
if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 {
if id, err := strconv.Atoi(parts[0]); err == nil {
libraryID = id
actualPath = parts[1]
}
}
// Case-insensitive comparison (like SQL's "collate nocase")
if strings.EqualFold(actualPath, dataPath) {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
Path: dataPath,
LibraryID: libraryID,
})
break
}
}
}
return mfs, nil
}

View File

@ -18,6 +18,7 @@ var Set = wire.NewSet(
NewShare,
NewPlaylists,
NewLibrary,
NewMaintenance,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),

12
go.mod
View File

@ -2,12 +2,8 @@ module github.com/navidrome/navidrome
go 1.25.4
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
)
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
require (
github.com/Masterminds/squirrel v1.5.4
@ -60,15 +56,15 @@ require (
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.9.0
github.com/tetratelabs/wazero v1.10.0
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.uber.org/goleak v1.3.0
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
golang.org/x/image v0.32.0
golang.org/x/net v0.46.0
golang.org/x/sync v0.17.0
golang.org/x/sys v0.37.0
golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0
golang.org/x/text v0.30.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.10

12
go.sum
View File

@ -265,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 v0.0.0-20251106165119-514cdb337684 h1:ugT1JTRsK1Jhn95BWilCugyZ1Svsyxm9xSiflOa2e7E=
github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
github.com/tetratelabs/wazero v1.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk=
github.com/tetratelabs/wazero v1.10.0/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=
@ -332,8 +332,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -350,8 +350,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=

View File

@ -11,6 +11,7 @@ import (
"runtime"
"sort"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
@ -70,6 +71,7 @@ type levelPath struct {
var (
currentLevel Level
loggerMu sync.RWMutex
defaultLogger = logrus.New()
logSourceLine = false
rootPath string
@ -79,7 +81,9 @@ var (
// SetLevel sets the global log level used by the simple logger.
func SetLevel(l Level) {
currentLevel = l
loggerMu.Lock()
defaultLogger.Level = logrus.TraceLevel
loggerMu.Unlock()
logrus.SetLevel(logrus.Level(l))
}
@ -125,6 +129,8 @@ func SetLogSourceLine(enabled bool) {
func SetRedacting(enabled bool) {
if enabled {
loggerMu.Lock()
defer loggerMu.Unlock()
defaultLogger.AddHook(redacted)
}
}
@ -133,6 +139,8 @@ func SetOutput(w io.Writer) {
if runtime.GOOS == "windows" {
w = CRLFWriter(w)
}
loggerMu.Lock()
defer loggerMu.Unlock()
defaultLogger.SetOutput(w)
}
@ -158,6 +166,8 @@ func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Conte
}
func SetDefaultLogger(l *logrus.Logger) {
loggerMu.Lock()
defer loggerMu.Unlock()
defaultLogger = l
}
@ -204,6 +214,8 @@ func log(level Level, args ...interface{}) {
}
func Writer() io.Writer {
loggerMu.RLock()
defer loggerMu.RUnlock()
return defaultLogger.Writer()
}
@ -314,6 +326,8 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
func createNewLogger() *logrus.Entry {
//logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true})
//l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}
loggerMu.RLock()
defer loggerMu.RUnlock()
logger := logrus.NewEntry(defaultLogger)
return logger
}

View File

@ -4,6 +4,8 @@ import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"sync"
"time"
@ -192,12 +194,43 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me
}, nil
}
// FindByPaths finds media files by their paths.
// The paths can be library-qualified (format: "libraryID:path") or unqualified ("path").
// Library-qualified paths search within the specified library, while unqualified paths
// search across all libraries for backward compatibility.
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
query := Or{}
for _, path := range paths {
parts := strings.SplitN(path, ":", 2)
if len(parts) == 2 {
// Library-qualified path: "libraryID:path"
libraryID, err := strconv.Atoi(parts[0])
if err != nil {
// Invalid format, skip
continue
}
relativePath := parts[1]
query = append(query, And{
Eq{"path collate nocase": relativePath},
Eq{"library_id": libraryID},
})
} else {
// Unqualified path: search across all libraries
query = append(query, Eq{"path collate nocase": path})
}
}
if len(query) == 0 {
return model.MediaFiles{}, nil
}
sel := r.newSelect().Columns("*").Where(query)
var res dbMediaFiles
if err := r.queryAll(sel, &res); err != nil {
return nil, err
}
return res.toModels(), nil
}

View File

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

View File

@ -400,8 +400,8 @@
},
"albumList": "Album",
"about": "Info",
"playlists": "Scalette",
"sharedPlaylists": "Scalette Condivise"
"playlists": "Playlist",
"sharedPlaylists": "Playlist Condivise"
},
"player": {
"playListsText": "Coda",

View File

@ -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 단축키",

View File

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

View File

@ -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
View 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": ""
}
}

View File

@ -11,27 +11,38 @@
"title": "標題",
"artist": "藝人",
"album": "專輯",
"path": "文件路徑",
"genre": "類型",
"path": "檔案路徑",
"libraryName": "媒體庫",
"genre": "曲風",
"compilation": "合輯",
"year": "發行年份",
"size": "檔案大小",
"updatedAt": "更新於",
"bitRate": "位元率",
"discSubtitle": "字幕",
"bitDepth": "位元深度",
"sampleRate": "取樣率",
"channels": "聲道",
"discSubtitle": "光碟副標題",
"starred": "收藏",
"comment": "註解",
"rating": "評分",
"quality": "品質",
"bpm": "BPM",
"playDate": "上次播放",
"channels": "聲道",
"createdAt": "創建於"
"createdAt": "建立於",
"grouping": "分組",
"mood": "情緒",
"participants": "其他參與人員",
"tags": "額外標籤",
"mappedTags": "分類後標籤",
"rawTags": "原始標籤",
"missing": "遺失"
},
"actions": {
"addToQueue": "加入至播放佇列",
"playNow": "立即播放",
"addToPlaylist": "加入至播放清單",
"showInPlaylist": "在播放清單中顯示",
"shuffleAll": "全部隨機播放",
"download": "下載",
"playNext": "下一首播放",
@ -44,38 +55,47 @@
"albumArtist": "專輯藝人",
"artist": "藝人",
"duration": "長度",
"songCount": "歌曲數",
"songCount": "歌曲數",
"playCount": "播放次數",
"size": "檔案大小",
"name": "名稱",
"genre": "類型",
"libraryName": "媒體庫",
"genre": "曲風",
"compilation": "合輯",
"year": "發行年份",
"updatedAt": "更新於",
"comment": "註解",
"rating": "評分",
"createdAt": "創建於",
"size": "檔案大小",
"date": "錄製日期",
"originalDate": "原始日期",
"releaseDate": "發行日期",
"releases": "發行",
"released": "已發行"
"released": "已發行",
"updatedAt": "更新於",
"comment": "註解",
"rating": "評分",
"createdAt": "建立於",
"recordLabel": "唱片公司",
"catalogNum": "目錄編號",
"releaseType": "發行類型",
"grouping": "分組",
"media": "媒體類型",
"mood": "情緒",
"missing": "遺失"
},
"actions": {
"playAll": "立即播放",
"playNext": "下首播放",
"playAll": "播放全部",
"playNext": "下首播放",
"addToQueue": "加入至播放佇列",
"share": "分享",
"shuffle": "隨機播放",
"addToPlaylist": "加入播放清單",
"addToPlaylist": "加入播放清單",
"download": "下載",
"info": "取得資訊",
"share": "分享"
"info": "取得資訊"
},
"lists": {
"all": "所有",
"random": "隨機",
"recentlyAdded": "最近加入",
"recentlyPlayed": "最近播放",
"mostPlayed": "最多播放的",
"mostPlayed": "最常播放",
"starred": "收藏",
"topRated": "最高評分"
}
@ -86,50 +106,80 @@
"name": "名稱",
"albumCount": "專輯數",
"songCount": "歌曲數",
"size": "檔案大小",
"playCount": "播放次數",
"rating": "評分",
"genre": "類型",
"size": "檔案大小"
"genre": "曲風",
"role": "參與角色",
"missing": "遺失"
},
"roles": {
"albumartist": "專輯藝人 |||| 專輯藝人",
"artist": "藝人 |||| 藝人",
"composer": "作曲 |||| 作曲",
"conductor": "指揮 |||| 指揮",
"lyricist": "作詞 |||| 作詞",
"arranger": "編曲 |||| 編曲",
"producer": "製作人 |||| 製作人",
"director": "導演 |||| 導演",
"engineer": "工程師 |||| 工程師",
"mixer": "混音師 |||| 混音師",
"remixer": "重混師 |||| 重混師",
"djmixer": "DJ 混音師 |||| DJ 混音師",
"performer": "表演者 |||| 表演者",
"maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人"
},
"actions": {
"topSongs": "熱門歌曲",
"shuffle": "隨機播放",
"radio": "電台"
}
},
"user": {
"name": "使用者 |||| 使用者",
"fields": {
"userName": "使用者名稱",
"isAdmin": "是否管理員",
"isAdmin": "管理員",
"lastLoginAt": "上次登入",
"lastAccessAt": "上此訪問",
"lastAccessAt": "上次存取",
"updatedAt": "更新於",
"name": "名稱",
"password": "密碼",
"createdAt": "創建於",
"createdAt": "於",
"changePassword": "變更密碼?",
"currentPassword": "現在的密碼",
"currentPassword": "目前密碼",
"newPassword": "新密碼",
"token": "權杖"
"token": "權杖",
"libraries": "媒體庫"
},
"helperTexts": {
"name": "你的名稱會在下次登入時生效"
"name": "您的名稱會在下次登入時生效",
"libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫"
},
"notifications": {
"created": "使用者已建",
"created": "使用者已",
"updated": "使用者已更新",
"deleted": "使用者已刪除"
},
"validation": {
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
},
"message": {
"listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
"clickHereForToken": "點擊此處來獲得你的 ListenBrainz 權杖"
"clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖",
"selectAllLibraries": "選取全部媒體庫",
"adminAutoLibraries": "管理員預設可存取所有媒體庫"
}
},
"player": {
"name": "用戶端 |||| 用戶端",
"name": "播放器 |||| 播放器",
"fields": {
"name": "名稱",
"transcodingId": "轉碼",
"maxBitRate": "最大位元率",
"client": "用戶端",
"client": "客戶端",
"userName": "使用者名稱",
"lastSeen": "上次瀏覽",
"lastSeen": "上次上線",
"reportRealPath": "回報實際路徑",
"scrobbleEnabled": "傳送音樂記錄至外部服務"
}
@ -140,7 +190,7 @@
"name": "名稱",
"targetFormat": "目標格式",
"defaultBitRate": "預設位元率",
"command": "命令"
"command": "指令"
}
},
"playlist": {
@ -151,70 +201,146 @@
"ownerName": "擁有者",
"public": "公開",
"updatedAt": "更新於",
"createdAt": "建於",
"createdAt": "於",
"songCount": "歌曲數",
"comment": "註解",
"sync": "自動入",
"path": "導入"
"sync": "自動入",
"path": "匯入來源"
},
"actions": {
"selectPlaylist": "選擇播放清單",
"addNewPlaylist": "創建 %{name}",
"export": "導出",
"selectPlaylist": "選取播放清單:",
"addNewPlaylist": "建立「%{name}」",
"export": "匯出",
"saveQueue": "將播放佇列儲存到播放清單",
"makePublic": "設為公開",
"makePrivate": "設為私人"
"makePrivate": "設為私人",
"searchOrCreate": "搜尋播放清單,或輸入名稱來新建…",
"pressEnterToCreate": "按 Enter 鍵建立新的播放清單",
"removeFromSelection": "移除選取項目"
},
"message": {
"duplicate_song": "加入重複的歌曲",
"song_exist": "有重複歌曲正在播放清單裡,您要加入或略過重複歌曲?"
"song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?",
"noPlaylistsFound": "找不到播放清單",
"noPlaylists": "暫無播放清單"
}
},
"radio": {
"name": "電台",
"name": "電台 |||| 電台",
"fields": {
"name": "名稱",
"streamUrl": "串流網址",
"homePageUrl": "首頁網址",
"updatedAt": "更新於",
"createdAt": "建於"
"createdAt": "於"
},
"actions": {
"playNow": "立即播放"
}
},
"share": {
"name": "分享",
"name": "分享 |||| 分享",
"fields": {
"username": "使用者名稱",
"username": "分享者",
"url": "網址",
"description": "描述",
"downloadable": "允許下載?",
"contents": "內容",
"expiresAt": "過期時間",
"lastVisitedAt": "上次時間",
"visitCount": "次數",
"lastVisitedAt": "上次訪時間",
"visitCount": "訪次數",
"format": "格式",
"maxBitRate": "最大位元率",
"updatedAt": "更新於",
"createdAt": "創建於",
"downloadable": "可下載"
"createdAt": "建立於"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "遺失檔案 |||| 遺失檔案",
"empty": "無遺失檔案",
"fields": {
"path": "路徑",
"size": "檔案大小",
"libraryName": "媒體庫",
"updatedAt": "遺失於"
},
"actions": {
"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": {
"auth": {
"welcome1": "感謝您安裝 Navidrome",
"welcome2": "開始前,請創建一個管理員帳戶",
"welcome2": "開始前,請先建立一個管理員帳號",
"confirmPassword": "確認密碼",
"buttonCreateAdmin": "創建管理員",
"auth_check_error": "請登入以訪問更多內容",
"user_menu": "配置",
"buttonCreateAdmin": "管理員",
"auth_check_error": "請登入以繼續",
"user_menu": "個人檔案",
"username": "使用者名稱",
"password": "密碼",
"sign_in": "登入",
"sign_in_error": "驗證失敗,請重試",
"logout": "登出"
"logout": "登出",
"insightsCollectionNote": "Navidrome 會收集匿名使用資料以協助改善項目。\n點擊[此處]了解更多資訊或選擇退出。"
},
"validation": {
"invalidChars": "請使用字母和數字",
@ -225,41 +351,41 @@
"minValue": "必須不小於 %{min}",
"maxValue": "必須不大於 %{max}",
"number": "必須為數字",
"email": "必須有效的電子郵件",
"oneOf": "必須為: %{options}其中一項",
"email": "必須有效的電子郵件",
"oneOf": "必須為以下其中一項:%{options}",
"regex": "必須符合指定的格式(正規表達式):%{pattern}",
"unique": "必須是唯一的",
"url": "網址"
"url": "必須為有效的網址"
},
"action": {
"add_filter": "加入篩選",
"add": "加入",
"back": "返回",
"bulk_actions": "選中 %{smart_count} 項",
"bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "取消",
"clear_input_value": "清除",
"clone": "複製",
"confirm": "確認",
"create": "建",
"create": "",
"delete": "刪除",
"edit": "編輯",
"export": "匯出",
"list": "列表",
"refresh": "重新整理",
"remove_filter": "清除此條件",
"remove": "清除",
"save": "保存",
"remove": "移除",
"save": "儲存",
"search": "搜尋",
"show": "顯示",
"sort": "排序",
"undo": "撤銷",
"undo": "復原",
"expand": "展開",
"close": "關閉",
"open_menu": "開選單",
"open_menu": "選單",
"close_menu": "關閉選單",
"unselect": "未選擇",
"unselect": "取消選取",
"skip": "略過",
"bulk_actions_mobile": "%{smart_count}",
"share": "分享",
"download": "下載"
},
@ -268,25 +394,25 @@
"false": "否"
},
"page": {
"create": "建 %{name}",
"create": " %{name}",
"dashboard": "儀表板",
"edit": "%{name} #%{id}",
"error": "發生錯誤",
"list": "%{name}",
"loading": "載入中",
"not_found": "未發現",
"not_found": "找不到",
"show": "%{name} #%{id}",
"empty": "還沒有 %{name}。",
"invite": "你要創建一個嗎?"
"invite": "您要建立一個嗎?"
},
"input": {
"file": {
"upload_several": "拖拽多個文件上傳或點擊選擇一個",
"upload_single": "拖拽單個文件上傳或點擊選擇一個"
"upload_several": "拖曳多個檔案上傳或點擊選擇一個",
"upload_single": "拖曳單個檔案上傳或點擊選擇一個"
},
"image": {
"upload_several": "拖多個圖片上傳或點擊選擇一個",
"upload_single": "拖單個圖片上傳或點擊選擇一個"
"upload_several": "拖多個圖片上傳或點擊選擇一個",
"upload_single": "拖單個圖片上傳或點擊選擇一個"
},
"references": {
"all_missing": "未找到參考數據",
@ -300,86 +426,98 @@
},
"message": {
"about": "關於",
"are_you_sure": "確定進行此操作",
"bulk_delete_content": "您確定要刪除 %{name} |||| 您確定要刪除 %{smart_count} 項?",
"are_you_sure": "您確定嗎",
"bulk_delete_content": "您確定要刪除 %{name} |||| 您確定要刪除 %{smart_count} 目嗎",
"bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}",
"delete_content": "您確定要刪除該項目?",
"delete_title": "刪除 %{name} #%{id}",
"details": "詳細資訊",
"error": "發生一個用戶端錯誤,您的請求無法完成",
"error": "發生戶端錯誤,您的請求無法完成",
"invalid_form": "提交內容無效,請檢查錯誤",
"loading": "正在載入頁面,請稍候",
"no": "否",
"not_found": "您輸入的連結格式不對或連結遺失",
"not_found": "您輸入了錯誤的連結或連結遺失",
"yes": "是",
"unsaved_changes": "某些更改尚未存,您確定要離開此頁面嗎?"
"unsaved_changes": "某些更改尚未存,您確定要離開此頁面嗎?"
},
"navigation": {
"no_results": "無內容",
"no_results": "沒有找到結果",
"no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁",
"page_out_of_boundaries": "頁碼 %{page} 超出邊界",
"page_out_from_end": "已經最後一頁",
"page_out_from_end": "已經最後一頁",
"page_out_from_begin": "已經是第一頁",
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
"page_rows_per_page": "每頁數:",
"page_rows_per_page": "每頁項目數:",
"next": "下一頁",
"prev": "上一頁",
"skip_nav": "跳"
"skip_nav": "跳至內容"
},
"notification": {
"updated": "項已更新 |||| %{smart_count} 項已更新",
"created": "項建",
"deleted": "項已刪除 |||| %{smart_count} 項已刪除",
"bad_item": "不確定的項",
"item_doesnt_exist": "項不存在",
"updated": "項已更新 |||| %{smart_count} 項已更新",
"created": "項已建",
"deleted": "項已刪除 |||| %{smart_count} 項已刪除",
"bad_item": "項目確",
"item_doesnt_exist": "項不存在",
"http_error": "伺服器通訊錯誤",
"data_provider_error": "資料來源錯誤,請檢查控制台的詳細資訊",
"i18n_error": "無法載入所選語言",
"canceled": "操作已取消",
"logged_out": "您的會話已結束,請重新登入",
"logged_out": "您的工作階段已結束,請重新登入",
"new_version": "發現新版本!請重新整理視窗"
},
"toggleFieldsMenu": {
"columnsToDisplay": "顯示欄",
"columnsToDisplay": "顯示欄",
"layout": "版面",
"grid": "框格",
"grid": "網格",
"table": "表格"
}
},
"message": {
"note": "註解",
"transcodingDisabled": "出於安全原因,禁用了從 Web 介面更改參數。要更改(編輯或新增)轉檔選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。",
"transcodingEnabled": "Navidrome 當前與 %{config} 一起使用,可以通過配置轉檔參數執行任意命令,建議僅在配置轉檔選項時啟用此功能。",
"songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已添加 %{smart_count} 首歌到播放清單",
"note": "注意",
"transcodingDisabled": "出於安全原因,已停用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。",
"transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。",
"songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單",
"noSimilarSongsFound": "找不到相似歌曲",
"noTopSongsFound": "找不到熱門歌曲",
"noPlaylistsAvailable": "沒有可用的播放清單",
"delete_user_title": "刪除使用者 %{name}",
"delete_user_content": "您確定要刪除該使用者及其相關數據(包括播放清單和使用者配置)嗎?",
"notifications_blocked": "您已在瀏覽器的設置中封鎖了此網站的通知",
"notifications_not_available": "此瀏覽器不支援桌面通知",
"lastfmLinkSuccess": "Last.fm 成功連接並開啟音樂記錄",
"lastfmLinkFailure": "Last.fm 無法連接",
"lastfmUnlinkSuccess": "Last.fm 已無連接並停用音樂記錄",
"lastfmUnlinkFailure": "Last.fm 無法取消連接",
"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 並開啟音樂記錄",
"lastfmLinkFailure": "無法連接 Last.fm",
"lastfmUnlinkSuccess": "已取消 Last.fm 的連接並停用音樂記錄",
"lastfmUnlinkFailure": "無法取消 Last.fm 的連接",
"listenBrainzLinkSuccess": "已成功以 %{user} 身份連接 ListenBrainz 並開啟音樂記錄",
"listenBrainzLinkFailure": "無法連接 ListenBrainz%{error}",
"listenBrainzUnlinkSuccess": "已取消 ListenBrainz 的連接並停用音樂記錄",
"listenBrainzUnlinkFailure": "無法取消 ListenBrainz 的連接",
"openIn": {
"lastfm": "在 Last.fm 打開",
"musicbrainz": "在 MusicBrainz 打開"
"lastfm": "在 Last.fm 中開啟",
"musicbrainz": "在 MusicBrainz 中開啟"
},
"lastfmLink": "繼續閱讀…",
"listenBrainzLinkSuccess": "ListenBrainz 成功連接並開啟音樂記錄",
"listenBrainzLinkFailure": "ListenBrainz 無法連接:%{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz 已無連接並停用音樂記錄",
"listenBrainzUnlinkFailure": "ListenBrainz 無法取消連接",
"downloadOriginalFormat": "下載原始格式",
"lastfmLink": "查看更多…",
"shareOriginalFormat": "分享原始格式",
"shareDialogTitle": "分享",
"shareBatchDialogTitle": "批次分享",
"shareSuccess": "分享成功",
"shareFailure": "分享失敗",
"downloadDialogTitle": "下載",
"shareCopyToClipboard": "複製到剪貼簿"
"shareDialogTitle": "分享 %{resource} '%{name}'",
"shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}",
"shareCopyToClipboard": "複製到剪貼簿Ctrl+C, Enter",
"shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}",
"shareFailure": "分享連結複製失敗:%{url}",
"downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "下載原始格式"
},
"menu": {
"library": "音樂庫",
"library": "媒體庫",
"librarySelector": {
"allLibraries": "所有媒體庫 (%{count})",
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
"selectLibraries": "選取媒體庫",
"none": "無"
},
"settings": "設定",
"version": "版本",
"theme": "主題",
@ -390,10 +528,11 @@
"language": "語言",
"defaultView": "預設畫面",
"desktop_notifications": "桌面通知",
"lastfmNotConfigured": "Last.fm API 金鑰未設定",
"lastfmScrobbling": "啟用 Last.fm 音樂記錄",
"listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
"replaygain": "重播增益",
"preAmp": "前置放大器 (dB)",
"replaygain": "重播增益模式",
"preAmp": "重播增益前置放大器 (dB)",
"gain": {
"none": "無",
"album": "專輯增益",
@ -402,20 +541,20 @@
}
},
"albumList": "專輯",
"about": "關於",
"playlists": "播放清單",
"sharedPlaylists": "分享的播放清單"
"sharedPlaylists": "分享的播放清單",
"about": "關於"
},
"player": {
"playListsText": "播放佇列",
"openText": "開",
"openText": "",
"closeText": "關閉",
"notContentText": "沒有音樂",
"clickToPlayText": "點擊播放",
"clickToPauseText": "點擊暫停",
"nextTrackText": "下一首",
"previousTrackText": "上一首",
"reloadText": "重新播放",
"reloadText": "重新載入",
"volumeText": "音量",
"toggleLyricText": "切換歌詞",
"toggleMiniModeText": "最小化",
@ -426,38 +565,66 @@
"emptyLyricText": "無歌詞",
"playModeText": {
"order": "順序播放",
"orderLoop": "列表循環",
"orderLoop": "循環播放",
"singleLoop": "單曲循環",
"shufflePlay": "隨機播放"
}
},
"about": {
"links": {
"homepage": "主頁",
"homepage": "首頁",
"source": "原始碼",
"featureRequests": "功能請求"
"featureRequests": "功能請求",
"lastInsightsCollection": "最近一次洞察資料收集",
"insights": {
"disabled": "已停用",
"waiting": "等待中"
}
},
"tabs": {
"about": "關於",
"config": "設定"
},
"config": {
"configName": "設定名稱",
"environmentVariable": "環境變數",
"currentValue": "目前值",
"configurationFile": "設定檔案",
"exportToml": "匯出設定TOML 格式)",
"exportSuccess": "設定已以 TOML 格式匯出至剪貼簿",
"exportFailed": "設定複製失敗",
"devFlagsHeader": "開發旗標(可能會更改/刪除)",
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除"
}
},
"activity": {
"title": "運作狀況",
"totalScanned": "已完成掃描的目錄",
"totalScanned": "已掃描的資料夾總數",
"quickScan": "快速掃描",
"fullScan": "完全掃描",
"serverUptime": "伺服器已運作時間",
"serverDown": "伺服器離線"
"serverUptime": "伺服器運作時間",
"serverDown": "伺服器已離線",
"scanType": "掃描類型",
"status": "掃描錯誤",
"elapsedTime": "經過時間"
},
"nowPlaying": {
"title": "正在播放",
"empty": "無播放內容",
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
},
"help": {
"title": "Navidrome 快捷鍵",
"hotkeys": {
"show_help": "顯示此幫助",
"show_help": "顯示此說明",
"toggle_menu": "顯示/隱藏選單側欄",
"toggle_play": "播放/暫停",
"prev_song": "上一首歌",
"next_song": "下一首歌",
"current_song": "前往目前歌曲",
"vol_up": "提高音量",
"vol_down": "降低音量",
"toggle_love": "添加或移除星標",
"current_song": "目前歌曲"
"toggle_love": "新增此歌曲至收藏"
}
}
}

View File

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

View File

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

View File

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

View File

@ -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) {
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")
err := ds.WithTx(func(tx model.DataStore) error {
var err error
if len(ids) == 0 {
_, err := tx.MediaFile(ctx).DeleteAllMissing()
return err
err = maintenance.DeleteAllMissingFiles(ctx)
} else {
err = maintenance.DeleteMissingFiles(ctx, ids)
}
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)
http.Error(w, "failed to delete missing files", 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)
} else {
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
}
}()
writeDeleteManyResponse(w, r, ids)
}
}
var _ model.ResourceRepository = &missingRepository{}

View File

@ -27,65 +27,66 @@ type Router struct {
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 {

View File

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

View File

@ -6,7 +6,10 @@ import (
"path/filepath"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/id"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
)
type testingT interface {
@ -35,3 +38,23 @@ func ClearDB() error {
`)
return err
}
// LogHook sets up a logrus test hook and configures the default logger to use it.
// It returns the hook and a cleanup function to restore the default logger.
// Example usage:
//
// hook, cleanup := LogHook()
// defer cleanup()
// // ... perform logging operations ...
// Expect(hook.LastEntry()).ToNot(BeNil())
// Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
// Expect(hook.LastEntry().Message).To(Equal("log message"))
func LogHook() (*test.Hook, func()) {
l, hook := test.NewNullLogger()
log.SetLevel(log.LevelWarn)
log.SetDefaultLogger(l)
return hook, func() {
// Restore default logger after test
log.SetDefaultLogger(logrus.New())
}
}

View File

@ -70,7 +70,7 @@ export default {
},
background: {
default: '#f0f2f5',
paper: 'inherit',
paper: bLight['500'],
},
text: {
secondary: '#232323',