mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-01 03:18:13 -05:00
Compare commits
7 Commits
copilot/fi
...
v0.58.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
131c0c565c | ||
|
|
53ff33866d | ||
|
|
508670ecfb | ||
|
|
c369224597 | ||
|
|
ff583970f0 | ||
|
|
38ca65726a | ||
|
|
5ce6e16d96 |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ var _ = Describe("CacheWarmer", func() {
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
226
core/maintenance.go
Normal file
226
core/maintenance.go
Normal 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
382
core/maintenance_test.go
Normal 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)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
@@ -160,6 +161,13 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Build.Settings, data.Build.GoVersion = buildInfo()
|
||||
data.OS.Containerized = consts.InContainer
|
||||
|
||||
// Install info
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
|
||||
packageFileData, err := os.ReadFile(packageFilename)
|
||||
if err == nil {
|
||||
data.OS.Package = string(packageFileData)
|
||||
}
|
||||
|
||||
// OS info
|
||||
data.OS.Type = runtime.GOOS
|
||||
data.OS.Arch = runtime.GOARCH
|
||||
|
||||
@@ -16,6 +16,7 @@ type Data struct {
|
||||
Containerized bool `json:"containerized"`
|
||||
Arch string `json:"arch"`
|
||||
NumCPU int `json:"numCPU"`
|
||||
Package string `json:"package,omitempty"`
|
||||
} `json:"os"`
|
||||
Mem struct {
|
||||
Alloc uint64 `json:"alloc"`
|
||||
|
||||
@@ -18,6 +18,7 @@ var Set = wire.NewSet(
|
||||
NewShare,
|
||||
NewPlaylists,
|
||||
NewLibrary,
|
||||
NewMaintenance,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
|
||||
14
go.mod
14
go.mod
@@ -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
|
||||
)
|
||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||
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
12
go.sum
@@ -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=
|
||||
|
||||
14
log/log.go
14
log/log.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -83,6 +83,15 @@ nfpms:
|
||||
owner: navidrome
|
||||
group: navidrome
|
||||
|
||||
- src: release/linux/.package.rpm # contents: "rpm"
|
||||
dst: /var/lib/navidrome/.package
|
||||
type: "config|noreplace"
|
||||
packager: rpm
|
||||
- src: release/linux/.package.deb # contents: "deb"
|
||||
dst: /var/lib/navidrome/.package
|
||||
type: "config|noreplace"
|
||||
packager: deb
|
||||
|
||||
scripts:
|
||||
preinstall: "release/linux/preinstall.sh"
|
||||
postinstall: "release/linux/postinstall.sh"
|
||||
|
||||
1
release/linux/.package.deb
Normal file
1
release/linux/.package.deb
Normal file
@@ -0,0 +1 @@
|
||||
deb
|
||||
1
release/linux/.package.rpm
Normal file
1
release/linux/.package.rpm
Normal file
@@ -0,0 +1 @@
|
||||
rpm
|
||||
@@ -49,6 +49,9 @@ cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUT
|
||||
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
|
||||
cp "$BINARY" "$MSI_OUTPUT_DIR"
|
||||
|
||||
# package type indicator file
|
||||
echo "msi" > "$MSI_OUTPUT_DIR/.package"
|
||||
|
||||
# workaround for wixl WixVariable not working to override bmp locations
|
||||
cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
|
||||
cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<Directory Id="ND_DATAFOLDER" name="[ND_DATAFOLDER]">
|
||||
<Component Id='PackageFile' Guid='9eec0697-803c-4629-858f-20dc376c960b' Win64="$(var.Win64)">
|
||||
<File Id='package' Name='.package' DiskId='1' Source='.package' KeyPath='no' />
|
||||
</Component>
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<InstallUISequence>
|
||||
@@ -81,6 +87,7 @@
|
||||
<ComponentRef Id='Configuration'/>
|
||||
<ComponentRef Id='MainExecutable' />
|
||||
<ComponentRef Id='FFMpegExecutable' />
|
||||
<ComponentRef Id='PackageFile' />
|
||||
</Feature>
|
||||
</Product>
|
||||
</Wix>
|
||||
|
||||
@@ -1,628 +0,0 @@
|
||||
{
|
||||
"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": ""
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
|
||||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
)
|
||||
|
||||
// User-library association endpoints (admin only)
|
||||
func (n *Router) addUserLibraryRoute(r chi.Router) {
|
||||
func (api *Router) addUserLibraryRoute(r chi.Router) {
|
||||
r.Route("/user/{id}/library", func(r chi.Router) {
|
||||
r.Use(parseUserIDMiddleware)
|
||||
r.Get("/", getUserLibraries(n.libs))
|
||||
r.Put("/", setUserLibraries(n.libs))
|
||||
r.Get("/", getUserLibraries(api.libs))
|
||||
r.Put("/", setUserLibraries(api.libs))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ var _ = Describe("Library API", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
@@ -63,45 +63,32 @@ func (r *missingRepository) EntityName() string {
|
||||
return "missing_files"
|
||||
}
|
||||
|
||||
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
|
||||
var err error
|
||||
if len(ids) == 0 {
|
||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||
return err
|
||||
}
|
||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||
})
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = ds.GC(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh artist stats in background after deleting missing files
|
||||
go func() {
|
||||
bgCtx := request.AddValues(context.Background(), r.Context())
|
||||
if _, err := ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
||||
err = maintenance.DeleteAllMissingFiles(ctx)
|
||||
} else {
|
||||
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
||||
err = maintenance.DeleteMissingFiles(ctx, ids)
|
||||
}
|
||||
}()
|
||||
|
||||
writeDeleteManyResponse(w, r, ids)
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "failed to delete missing files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeDeleteManyResponse(w, r, ids)
|
||||
}
|
||||
}
|
||||
|
||||
var _ model.ResourceRepository = &missingRepository{}
|
||||
|
||||
@@ -22,70 +22,71 @@ import (
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
share core.Share
|
||||
playlists core.Playlists
|
||||
insights metrics.Insights
|
||||
libs core.Library
|
||||
ds model.DataStore
|
||||
share core.Share
|
||||
playlists core.Playlists
|
||||
insights metrics.Insights
|
||||
libs core.Library
|
||||
maintenance core.Maintenance
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService}
|
||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
}
|
||||
|
||||
func (n *Router) routes() http.Handler {
|
||||
func (api *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Public
|
||||
n.RX(r, "/translation", newTranslationRepository, false)
|
||||
api.RX(r, "/translation", newTranslationRepository, false)
|
||||
|
||||
// Protected
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.Authenticator(n.ds))
|
||||
r.Use(server.Authenticator(api.ds))
|
||||
r.Use(server.JWTRefresher)
|
||||
r.Use(server.UpdateLastAccessMiddleware(n.ds))
|
||||
n.R(r, "/user", model.User{}, true)
|
||||
n.R(r, "/song", model.MediaFile{}, false)
|
||||
n.R(r, "/album", model.Album{}, false)
|
||||
n.R(r, "/artist", model.Artist{}, false)
|
||||
n.R(r, "/genre", model.Genre{}, false)
|
||||
n.R(r, "/player", model.Player{}, true)
|
||||
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
n.R(r, "/radio", model.Radio{}, true)
|
||||
n.R(r, "/tag", model.Tag{}, true)
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
api.R(r, "/user", model.User{}, true)
|
||||
api.R(r, "/song", model.MediaFile{}, false)
|
||||
api.R(r, "/album", model.Album{}, false)
|
||||
api.R(r, "/artist", model.Artist{}, false)
|
||||
api.R(r, "/genre", model.Genre{}, false)
|
||||
api.R(r, "/player", model.Player{}, true)
|
||||
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
api.R(r, "/radio", model.Radio{}, true)
|
||||
api.R(r, "/tag", model.Tag{}, true)
|
||||
if conf.Server.EnableSharing {
|
||||
n.RX(r, "/share", n.share.NewRepository, true)
|
||||
api.RX(r, "/share", api.share.NewRepository, true)
|
||||
}
|
||||
|
||||
n.addPlaylistRoute(r)
|
||||
n.addPlaylistTrackRoute(r)
|
||||
n.addSongPlaylistsRoute(r)
|
||||
n.addQueueRoute(r)
|
||||
n.addMissingFilesRoute(r)
|
||||
n.addKeepAliveRoute(r)
|
||||
n.addInsightsRoute(r)
|
||||
api.addPlaylistRoute(r)
|
||||
api.addPlaylistTrackRoute(r)
|
||||
api.addSongPlaylistsRoute(r)
|
||||
api.addQueueRoute(r)
|
||||
api.addMissingFilesRoute(r)
|
||||
api.addKeepAliveRoute(r)
|
||||
api.addInsightsRoute(r)
|
||||
|
||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||
n.addInspectRoute(r)
|
||||
n.addConfigRoute(r)
|
||||
n.addUserLibraryRoute(r)
|
||||
n.RX(r, "/library", n.libs.NewRepository, true)
|
||||
api.addInspectRoute(r)
|
||||
api.addConfigRoute(r)
|
||||
api.addUserLibraryRoute(r)
|
||||
api.RX(r, "/library", api.libs.NewRepository, true)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
|
||||
func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return n.ds.Resource(ctx, model)
|
||||
return api.ds.Resource(ctx, model)
|
||||
}
|
||||
n.RX(r, pathPrefix, constructor, persistable)
|
||||
api.RX(r, pathPrefix, constructor, persistable)
|
||||
}
|
||||
|
||||
func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
r.Route(pathPrefix, func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
if persistable {
|
||||
@@ -102,9 +103,9 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addPlaylistRoute(r chi.Router) {
|
||||
func (api *Router) addPlaylistRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return n.ds.Resource(ctx, model.Playlist{})
|
||||
return api.ds.Resource(ctx, model.Playlist{})
|
||||
}
|
||||
|
||||
r.Route("/playlist", func(r chi.Router) {
|
||||
@@ -114,7 +115,7 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
|
||||
rest.Post(constructor)(w, r)
|
||||
return
|
||||
}
|
||||
createPlaylistFromM3U(n.playlists)(w, r)
|
||||
createPlaylistFromM3U(api.playlists)(w, r)
|
||||
})
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
@@ -126,55 +127,53 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
func (api *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylist(n.ds)(w, r)
|
||||
getPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(n.ds)(w, r)
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
addToPlaylist(n.ds)(w, r)
|
||||
addToPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylistTrack(n.ds)(w, r)
|
||||
getPlaylistTrack(api.ds)(w, r)
|
||||
})
|
||||
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
reorderItem(n.ds)(w, r)
|
||||
reorderItem(api.ds)(w, r)
|
||||
})
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(n.ds)(w, r)
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
func (api *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
|
||||
getSongPlaylists(n.ds)(w, r)
|
||||
getSongPlaylists(api.ds)(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addQueueRoute(r chi.Router) {
|
||||
func (api *Router) addQueueRoute(r chi.Router) {
|
||||
r.Route("/queue", func(r chi.Router) {
|
||||
r.Get("/", getQueue(n.ds))
|
||||
r.Post("/", saveQueue(n.ds))
|
||||
r.Put("/", updateQueue(n.ds))
|
||||
r.Delete("/", clearQueue(n.ds))
|
||||
r.Get("/", getQueue(api.ds))
|
||||
r.Post("/", saveQueue(api.ds))
|
||||
r.Put("/", updateQueue(api.ds))
|
||||
r.Delete("/", clearQueue(api.ds))
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
||||
func (api *Router) addMissingFilesRoute(r chi.Router) {
|
||||
r.Route("/missing", func(r chi.Router) {
|
||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteMissingFiles(n.ds, w, r)
|
||||
})
|
||||
api.RX(r, "/", newMissingRepository(api.ds), false)
|
||||
r.Delete("/", deleteMissingFiles(api.maintenance))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -198,7 +197,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addInspectRoute(r chi.Router) {
|
||||
func (api *Router) addInspectRoute(r chi.Router) {
|
||||
if conf.Server.Inspect.Enabled {
|
||||
r.Group(func(r chi.Router) {
|
||||
if conf.Server.Inspect.MaxRequests > 0 {
|
||||
@@ -207,26 +206,26 @@ func (n *Router) addInspectRoute(r chi.Router) {
|
||||
conf.Server.Inspect.BacklogTimeout)
|
||||
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
|
||||
}
|
||||
r.Get("/inspect", inspect(n.ds))
|
||||
r.Get("/inspect", inspect(api.ds))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addConfigRoute(r chi.Router) {
|
||||
func (api *Router) addConfigRoute(r chi.Router) {
|
||||
if conf.Server.DevUIShowConfig {
|
||||
r.Get("/config/*", getConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addKeepAliveRoute(r chi.Router) {
|
||||
func (api *Router) addKeepAliveRoute(r chi.Router) {
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addInsightsRoute(r chi.Router) {
|
||||
func (api *Router) addInsightsRoute(r chi.Router) {
|
||||
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
last, success := n.insights.LastRun(r.Context())
|
||||
last, success := api.insights.LastRun(r.Context())
|
||||
if conf.Server.EnableInsightsCollector {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
||||
} else {
|
||||
|
||||
@@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
mfRepo.SetData(testSongs)
|
||||
|
||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
@@ -148,7 +148,9 @@ func (api *Router) routes() http.Handler {
|
||||
h(r, "createBookmark", api.CreateBookmark)
|
||||
h(r, "deleteBookmark", api.DeleteBookmark)
|
||||
h(r, "getPlayQueue", api.GetPlayQueue)
|
||||
h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
|
||||
h(r, "savePlayQueue", api.SavePlayQueue)
|
||||
h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
|
||||
@@ -91,7 +91,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
Current: currentID,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: &pq.UpdatedAt,
|
||||
Changed: pq.UpdatedAt,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
}
|
||||
return response, nil
|
||||
@@ -135,3 +135,74 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
pq, err := repo.RetrieveWithMediaFiles(user.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if pq == nil || len(pq.Items) == 0 {
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
|
||||
var index *int
|
||||
if len(pq.Items) > 0 {
|
||||
index = &pq.Current
|
||||
}
|
||||
|
||||
response.PlayQueueByIndex = &responses.PlayQueueByIndex{
|
||||
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
|
||||
CurrentIndex: index,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: pq.UpdatedAt,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
|
||||
position := p.Int64Or("position", 0)
|
||||
|
||||
var err error
|
||||
var currentIndex int
|
||||
|
||||
if len(ids) > 0 {
|
||||
currentIndex, err = p.Int("currentIndex")
|
||||
if err != nil || currentIndex < 0 || currentIndex >= len(ids) {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
items := slice.Map(ids, func(id string) model.MediaFile {
|
||||
return model.MediaFile{ID: id}
|
||||
})
|
||||
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
client, _ := request.ClientFrom(r.Context())
|
||||
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: currentIndex,
|
||||
Position: position,
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
}
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
err = repo.Store(pq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
|
||||
{Name: "transcodeOffset", Versions: []int32{1}},
|
||||
{Name: "formPost", Versions: []int32{1}},
|
||||
{Name: "songLyrics", Versions: []int32{1}},
|
||||
{Name: "indexBasedQueue", Versions: []int32{1}},
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -35,10 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(3),
|
||||
HaveLen(4),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"openSubsonic": true,
|
||||
"playQueue": {
|
||||
"username": "",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueue username="" changedBy=""></playQueue>
|
||||
<playQueue username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueue>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"playQueueByIndex": {
|
||||
"entry": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"currentIndex": 0,
|
||||
"position": 243,
|
||||
"username": "user1",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": "a_client"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
</playQueueByIndex>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"playQueueByIndex": {
|
||||
"username": "",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueueByIndex username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueueByIndex>
|
||||
</subsonic-response>
|
||||
@@ -60,6 +60,7 @@ type Subsonic struct {
|
||||
// OpenSubsonic extensions
|
||||
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -439,12 +440,21 @@ type TopSongs struct {
|
||||
}
|
||||
|
||||
type PlayQueue struct {
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
}
|
||||
|
||||
type PlayQueueByIndex struct {
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
}
|
||||
|
||||
type Bookmark struct {
|
||||
|
||||
@@ -768,7 +768,7 @@ var _ = Describe("Responses", func() {
|
||||
response.PlayQueue.Username = "user1"
|
||||
response.PlayQueue.Current = "111"
|
||||
response.PlayQueue.Position = 243
|
||||
response.PlayQueue.Changed = &time.Time{}
|
||||
response.PlayQueue.Changed = time.Time{}
|
||||
response.PlayQueue.ChangedBy = "a_client"
|
||||
child := make([]Child, 1)
|
||||
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
||||
@@ -783,6 +783,40 @@ var _ = Describe("Responses", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PlayQueueByIndex", func() {
|
||||
BeforeEach(func() {
|
||||
response.PlayQueueByIndex = &PlayQueueByIndex{}
|
||||
})
|
||||
|
||||
Context("without data", func() {
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
response.PlayQueueByIndex.Username = "user1"
|
||||
response.PlayQueueByIndex.CurrentIndex = gg.P(0)
|
||||
response.PlayQueueByIndex.Position = 243
|
||||
response.PlayQueueByIndex.Changed = time.Time{}
|
||||
response.PlayQueueByIndex.ChangedBy = "a_client"
|
||||
child := make([]Child, 1)
|
||||
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
||||
response.PlayQueueByIndex.Entry = child
|
||||
})
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Shares", func() {
|
||||
BeforeEach(func() {
|
||||
response.Shares = &Shares{}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user