Merge branch 'master' into msi-insights-detection

This commit is contained in:
Rob Emery 2025-05-21 19:14:22 +01:00
commit 114f870f02
35 changed files with 576 additions and 44 deletions

4
.gitignore vendored
View File

@ -24,4 +24,6 @@ docker-compose.yml
!contrib/docker-compose.yml !contrib/docker-compose.yml
binaries binaries
navidrome-master navidrome-master
*.exe AGENTS.md
*.exe
bin/

View File

@ -157,10 +157,10 @@ package: docker-build ##@Cross_Compilation Create binaries and packages for ALL
get-music: ##@Development Download some free music from Navidrome's demo instance get-music: ##@Development Download some free music from Navidrome's demo instance
mkdir -p music mkdir -p music
( cd music; \ ( cd music; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \ curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=2Y3qQA6zJC3ObbBrF9ZBoV" > brock.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \ curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=04HrSORpypcLGNUdQp37gn" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \ curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=5xcMPJdeEgNrGtnzYbzAqb" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \ curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=1jjQMAZrG3lUsJ0YH6ZRS0" > voodoocuts.zip; \
for file in *.zip; do unzip -n $${file}; done ) for file in *.zip; do unzip -n $${file}; done )
@echo "Done. Remember to set your MusicFolder to ./music" @echo "Done. Remember to set your MusicFolder to ./music"
.PHONY: get-music .PHONY: get-music

View File

@ -72,6 +72,7 @@ type configOptions struct {
EnableUserEditing bool EnableUserEditing bool
EnableSharing bool EnableSharing bool
ShareURL string ShareURL string
DefaultShareExpiration time.Duration
DefaultDownloadableShare bool DefaultDownloadableShare bool
DefaultTheme string DefaultTheme string
DefaultLanguage string DefaultLanguage string
@ -472,6 +473,7 @@ func init() {
viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablesharing", false) viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "") viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("defaultdownloadableshare", false)
viper.SetDefault("gatrackingid", "") viper.SetDefault("gatrackingid", "")
viper.SetDefault("enableinsightscollector", true) viper.SetDefault("enableinsightscollector", true)

View File

@ -14,6 +14,9 @@ const (
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal" DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
InitialSetupFlagKey = "InitialSetup" InitialSetupFlagKey = "InitialSetup"
FullScanAfterMigrationFlagKey = "FullScanAfterMigration" FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
LastScanErrorKey = "LastScanError"
LastScanTypeKey = "LastScanType"
LastScanStartTimeKey = "LastScanStartTime"
UIAuthorizationHeader = "X-ND-Authorization" UIAuthorizationHeader = "X-ND-Authorization"
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"

View File

@ -8,6 +8,7 @@ import (
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/deluan/rest" "github.com/deluan/rest"
gonanoid "github.com/matoous/go-nanoid/v2" gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg" . "github.com/navidrome/navidrome/utils/gg"
@ -93,7 +94,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
} }
s.ID = id s.ID = id
if V(s.ExpiresAt).IsZero() { if V(s.ExpiresAt).IsZero() {
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour)) s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
} }
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]

View File

@ -32,6 +32,8 @@ type Artist struct {
SimilarArtists Artists `structs:"similar_artists" json:"-"` SimilarArtists Artists `structs:"similar_artists" json:"-"`
ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt,omitempty"` ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt,omitempty"`
Missing bool `structs:"missing" json:"missing"`
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"` CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"` UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
} }
@ -76,7 +78,7 @@ type ArtistRepository interface {
UpdateExternalInfo(a *Artist) error UpdateExternalInfo(a *Artist) error
Get(id string) (*Artist, error) Get(id string) (*Artist, error)
GetAll(options ...QueryOptions) (Artists, error) GetAll(options ...QueryOptions) (Artists, error)
GetIndex(roles ...Role) (ArtistIndexes, error) GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error)
// The following methods are used exclusively by the scanner: // The following methods are used exclusively by the scanner:
RefreshPlayCounts() (int64, error) RefreshPlayCounts() (int64, error)

View File

@ -191,6 +191,7 @@ const (
TagReleaseCountry TagName = "releasecountry" TagReleaseCountry TagName = "releasecountry"
TagMedia TagName = "media" TagMedia TagName = "media"
TagCatalogNumber TagName = "catalognumber" TagCatalogNumber TagName = "catalognumber"
TagISRC TagName = "isrc"
TagBPM TagName = "bpm" TagBPM TagName = "bpm"
TagExplicitStatus TagName = "explicitstatus" TagExplicitStatus TagName = "explicitstatus"

View File

@ -116,6 +116,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
"name": fullTextFilter(r.tableName), "name": fullTextFilter(r.tableName),
"starred": booleanFilter, "starred": booleanFilter,
"role": roleFilter, "role": roleFilter,
"missing": booleanFilter,
}) })
r.setSortMappings(map[string]string{ r.setSortMappings(map[string]string{
"name": "order_artist_name", "name": "order_artist_name",
@ -202,7 +203,7 @@ func (r *artistRepository) getIndexKey(a model.Artist) string {
} }
// TODO Cache the index (recalculate when there are changes to the DB) // TODO Cache the index (recalculate when there are changes to the DB)
func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, error) { func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (model.ArtistIndexes, error) {
options := model.QueryOptions{Sort: "name"} options := model.QueryOptions{Sort: "name"}
if len(roles) > 0 { if len(roles) > 0 {
roleFilters := slice.Map(roles, func(r model.Role) Sqlizer { roleFilters := slice.Map(roles, func(r model.Role) Sqlizer {
@ -210,6 +211,13 @@ func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, e
}) })
options.Filters = And(roleFilters) options.Filters = And(roleFilters)
} }
if !includeMissing {
if options.Filters == nil {
options.Filters = Eq{"artist.missing": false}
} else {
options.Filters = And{options.Filters, Eq{"artist.missing": false}}
}
}
artists, err := r.GetAll(options) artists, err := r.GetAll(options)
if err != nil { if err != nil {
return nil, err return nil, err
@ -236,6 +244,26 @@ func (r *artistRepository) purgeEmpty() error {
return nil return nil
} }
// markMissing sets the Missing flag based on album data.
func (r *artistRepository) markMissing() (int64, error) {
q := Expr(`
update artist
set missing = not exists (
select 1 from album_artists aa
join album a on aa.album_id = a.id
where aa.artist_id = artist.id and a.missing = false
)
`)
c, err := r.executeSQL(q)
if err != nil {
return 0, fmt.Errorf("marking missing artists: %w", err)
}
if c > 0 {
log.Debug(r.ctx, "Marked missing artists", "totalUpdated", c)
}
return c, nil
}
// RefreshPlayCounts updates the play count and last play date annotations for all artists, based // RefreshPlayCounts updates the play count and last play date annotations for all artists, based
// on the media files associated with them. // on the media files associated with them.
func (r *artistRepository) RefreshPlayCounts() (int64, error) { func (r *artistRepository) RefreshPlayCounts() (int64, error) {

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
@ -94,7 +95,7 @@ var _ = Describe("ArtistRepository", func() {
er := repo.Put(&artistBeatles) er := repo.Put(&artistBeatles)
Expect(er).To(BeNil()) Expect(er).To(BeNil())
idx, err := repo.GetIndex() idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2)) Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("F")) Expect(idx[0].ID).To(Equal("F"))
@ -112,7 +113,7 @@ var _ = Describe("ArtistRepository", func() {
// BFR Empty SortArtistName is not saved in the DB anymore // BFR Empty SortArtistName is not saved in the DB anymore
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() { XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
idx, err := repo.GetIndex() idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2)) Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B")) Expect(idx[0].ID).To(Equal("B"))
@ -134,7 +135,7 @@ var _ = Describe("ArtistRepository", func() {
er := repo.Put(&artistBeatles) er := repo.Put(&artistBeatles)
Expect(er).To(BeNil()) Expect(er).To(BeNil())
idx, err := repo.GetIndex() idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2)) Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B")) Expect(idx[0].ID).To(Equal("B"))
@ -151,7 +152,7 @@ var _ = Describe("ArtistRepository", func() {
}) })
It("returns the index when SortArtistName is empty", func() { It("returns the index when SortArtistName is empty", func() {
idx, err := repo.GetIndex() idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2)) Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B")) Expect(idx[0].ID).To(Equal("B"))
@ -233,5 +234,91 @@ var _ = Describe("ArtistRepository", func() {
Expect(m).ToNot(HaveKey("mbz_artist_id")) Expect(m).ToNot(HaveKey("mbz_artist_id"))
}) })
}) })
Describe("Missing artist visibility", func() {
var raw *artistRepository
var missing model.Artist
insertMissing := func() {
missing = model.Artist{ID: "m1", Name: "Missing", OrderArtistName: "missing"}
Expect(repo.Put(&missing)).To(Succeed())
raw = repo.(*artistRepository)
_, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missing.ID}))
Expect(err).ToNot(HaveOccurred())
}
removeMissing := func() {
if raw != nil {
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missing.ID}))
}
}
Context("regular user", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "u1"})
repo = NewArtistRepository(ctx, GetDBXBuilder())
insertMissing()
})
AfterEach(func() { removeMissing() })
It("does not return missing artist in GetAll", func() {
artists, err := repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.missing": false}})
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(2))
})
It("does not return missing artist in Search", func() {
res, err := repo.Search("missing", 0, 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(res).To(BeEmpty())
})
It("does not return missing artist in GetIndex", func() {
idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred())
// Only 2 artists should be present
total := 0
for _, ix := range idx {
total += len(ix.Artists)
}
Expect(total).To(Equal(2))
})
})
Context("admin user", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true})
repo = NewArtistRepository(ctx, GetDBXBuilder())
insertMissing()
})
AfterEach(func() { removeMissing() })
It("returns missing artist in GetAll", func() {
artists, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(3))
})
It("returns missing artist in Search", func() {
res, err := repo.Search("missing", 0, 10, true)
Expect(err).ToNot(HaveOccurred())
Expect(res).To(HaveLen(1))
})
It("returns missing artist in GetIndex when included", func() {
idx, err := repo.GetIndex(true)
Expect(err).ToNot(HaveOccurred())
total := 0
for _, ix := range idx {
total += len(ix.Artists)
}
Expect(total).To(Equal(3))
})
})
})
}) })
}) })

View File

@ -170,6 +170,7 @@ func (s *SQLStore) GC(ctx context.Context) error {
err := chain.RunSequentially( err := chain.RunSequentially(
trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }), trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }),
trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }), trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }),
trace(ctx, "mark missing artists", func() error { _, err := s.Artist(ctx).(*artistRepository).markMissing(); return err }),
trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }), trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }),
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }), trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }), trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),

View File

@ -9,6 +9,7 @@ import (
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/auth"
@ -37,6 +38,9 @@ type StatusInfo struct {
LastScan time.Time LastScan time.Time
Count uint32 Count uint32
FolderCount uint32 FolderCount uint32
LastError string
ScanType string
ElapsedTime time.Duration
} }
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker, func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
@ -113,20 +117,51 @@ type controller struct {
changesDetected bool changesDetected bool
} }
// getScanInfo retrieves scan status from the database
func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) {
lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
scanType, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
startTimeStr, _ := s.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
if startTimeStr != "" {
startTime, err := time.Parse(time.RFC3339, startTimeStr)
if err == nil {
if running.Load() {
elapsed = time.Since(startTime)
} else {
// If scan is not running, try to get the last scan time for the library
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
if err == nil {
elapsed = lib.LastScanAt.Sub(startTime)
}
}
}
}
return scanType, elapsed, lastErr
}
func (s *controller) Status(ctx context.Context) (*StatusInfo, error) { func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
if err != nil { if err != nil {
return nil, fmt.Errorf("getting library: %w", err) return nil, fmt.Errorf("getting library: %w", err)
} }
scanType, elapsed, lastErr := s.getScanInfo(ctx)
if running.Load() { if running.Load() {
status := &StatusInfo{ status := &StatusInfo{
Scanning: true, Scanning: true,
LastScan: lib.LastScanAt, LastScan: lib.LastScanAt,
Count: s.count.Load(), Count: s.count.Load(),
FolderCount: s.folderCount.Load(), FolderCount: s.folderCount.Load(),
LastError: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
} }
return status, nil return status, nil
} }
count, folderCount, err := s.getCounters(ctx) count, folderCount, err := s.getCounters(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("getting library stats: %w", err) return nil, fmt.Errorf("getting library stats: %w", err)
@ -136,6 +171,9 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
LastScan: lib.LastScanAt, LastScan: lib.LastScanAt,
Count: uint32(count), Count: uint32(count),
FolderCount: uint32(folderCount), FolderCount: uint32(folderCount),
LastError: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
}, nil }, nil
} }
@ -193,10 +231,14 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
if count, folderCount, err := s.getCounters(ctx); err != nil { if count, folderCount, err := s.getCounters(ctx); err != nil {
return scanWarnings, err return scanWarnings, err
} else { } else {
scanType, elapsed, lastErr := s.getScanInfo(ctx)
s.sendMessage(ctx, &events.ScanStatus{ s.sendMessage(ctx, &events.ScanStatus{
Scanning: false, Scanning: false,
Count: count, Count: count,
FolderCount: folderCount, FolderCount: folderCount,
Error: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
}) })
} }
return scanWarnings, scanError return scanWarnings, scanError
@ -240,10 +282,15 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
if p.FileCount > 0 { if p.FileCount > 0 {
s.folderCount.Add(1) s.folderCount.Add(1)
} }
scanType, elapsed, lastErr := s.getScanInfo(ctx)
status := &events.ScanStatus{ status := &events.ScanStatus{
Scanning: true, Scanning: true,
Count: int64(s.count.Load()), Count: int64(s.count.Load()),
FolderCount: int64(s.folderCount.Load()), FolderCount: int64(s.folderCount.Load()),
Error: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
} }
if s.limiter != nil { if s.limiter != nil {
s.limiter.Do(func() { s.sendMessage(ctx, status) }) s.limiter.Do(func() { s.sendMessage(ctx, status) })

View File

@ -0,0 +1,57 @@
package scanner_test
import (
"context"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Controller", func() {
var ctx context.Context
var ds *tests.MockDataStore
var ctrl scanner.Scanner
Describe("Status", func() {
BeforeEach(func() {
ctx = context.Background()
db.Init(ctx)
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
ds.MockedProperty = &tests.MockedPropertyRepo{}
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Name: "lib", Path: "/tmp"})).To(Succeed())
})
It("includes last scan error", func() {
Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "boom")).To(Succeed())
status, err := ctrl.Status(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(status.LastError).To(Equal("boom"))
})
It("includes scan type and error in status", func() {
// Set up test data in property repo
Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "test error")).To(Succeed())
Expect(ds.Property(ctx).Put(consts.LastScanTypeKey, "full")).To(Succeed())
// Get status and verify basic info
status, err := ctrl.Status(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(status.LastError).To(Equal("test error"))
Expect(status.ScanType).To(Equal("full"))
})
})
})

View File

@ -57,12 +57,21 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
startTime := time.Now() startTime := time.Now()
log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs)) log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs))
// Store scan type and start time
scanType := "quick"
if state.fullScan {
scanType = "full"
}
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType)
_ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339))
// if there was a full scan in progress, force a full scan // if there was a full scan in progress, force a full scan
if !state.fullScan { if !state.fullScan {
for _, lib := range libs { for _, lib := range libs {
if lib.FullScanInProgress { if lib.FullScanInProgress {
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name) log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
state.fullScan = true state.fullScan = true
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
break break
} }
} }
@ -100,11 +109,14 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
) )
if err != nil { if err != nil {
log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err)
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error())
state.sendError(err) state.sendError(err)
s.metrics.WriteAfterScanMetrics(ctx, false) s.metrics.WriteAfterScanMetrics(ctx, false)
return return
} }
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, "")
if state.changesDetected.Load() { if state.changesDetected.Load() {
state.sendProgress(&ProgressInfo{ChangesDetected: true}) state.sendProgress(&ProgressInfo{ChangesDetected: true})
} }

View File

@ -37,9 +37,12 @@ func (e *baseEvent) Data(evt Event) string {
type ScanStatus struct { type ScanStatus struct {
baseEvent baseEvent
Scanning bool `json:"scanning"` Scanning bool `json:"scanning"`
Count int64 `json:"count"` Count int64 `json:"count"`
FolderCount int64 `json:"folderCount"` FolderCount int64 `json:"folderCount"`
Error string `json:"error"`
ScanType string `json:"scanType"`
ElapsedTime time.Duration `json:"elapsedTime"`
} }
type KeepAlive struct { type KeepAlive struct {

View File

@ -38,7 +38,7 @@ func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Ti
var indexes model.ArtistIndexes var indexes model.ArtistIndexes
if lib.LastScanAt.After(ifModifiedSince) { if lib.LastScanAt.After(ifModifiedSince) {
indexes, err = api.ds.Artist(ctx).GetIndex(model.RoleAlbumArtist) indexes, err = api.ds.Artist(ctx).GetIndex(false, model.RoleAlbumArtist)
if err != nil { if err != nil {
log.Error(ctx, "Error retrieving Indexes", err) log.Error(ctx, "Error retrieving Indexes", err)
return nil, 0, err return nil, 0, err

View File

@ -108,12 +108,19 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
return addDefaultFilters(options) return addDefaultFilters(options)
} }
func SongWithArtistTitle(artist, title string) Options { func SongWithLyrics(artist, title string) Options {
return addDefaultFilters(Options{ return addDefaultFilters(Options{
Sort: "updated_at", Sort: "updated_at",
Order: "desc", Order: "desc",
Max: 1, Max: 1,
Filters: And{Eq{"artist": artist, "title": title}}, Filters: And{
Eq{"title": title},
NotEq{"lyrics": "[]"},
Or{
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
},
},
}) })
} }

View File

@ -224,6 +224,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op
child.BPM = int32(mf.BPM) child.BPM = int32(mf.BPM)
child.MediaType = responses.MediaTypeSong child.MediaType = responses.MediaTypeSong
child.MusicBrainzId = mf.MbzRecordingID child.MusicBrainzId = mf.MbzRecordingID
child.Isrc = mf.Tags.Values(model.TagISRC)
child.ReplayGain = responses.ReplayGain{ child.ReplayGain = responses.ReplayGain{
TrackGain: mf.RGTrackGain, TrackGain: mf.RGTrackGain,
AlbumGain: mf.RGAlbumGain, AlbumGain: mf.RGAlbumGain,

View File

@ -23,6 +23,9 @@ func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) {
Count: int64(status.Count), Count: int64(status.Count),
FolderCount: int64(status.FolderCount), FolderCount: int64(status.FolderCount),
LastScan: &status.LastScan, LastScan: &status.LastScan,
Error: status.LastError,
ScanType: status.ScanType,
ElapsedTime: int64(status.ElapsedTime),
} }
return response, nil return response, nil
} }

View File

@ -98,7 +98,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
response := newResponse() response := newResponse()
lyricsResponse := responses.Lyrics{} lyricsResponse := responses.Lyrics{}
response.Lyrics = &lyricsResponse response.Lyrics = &lyricsResponse
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithArtistTitle(artist, title)) mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithLyrics(artist, title))
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -15,6 +15,7 @@
"sortName": "sort name", "sortName": "sort name",
"mediaType": "album", "mediaType": "album",
"musicBrainzId": "00000000-0000-0000-0000-000000000000", "musicBrainzId": "00000000-0000-0000-0000-000000000000",
"isrc": [],
"genres": [ "genres": [
{ {
"name": "Genre 1" "name": "Genre 1"

View File

@ -99,6 +99,9 @@
"sortName": "sorted song", "sortName": "sorted song",
"mediaType": "song", "mediaType": "song",
"musicBrainzId": "4321", "musicBrainzId": "4321",
"isrc": [
"ISRC-1"
],
"genres": [ "genres": [
{ {
"name": "rock" "name": "rock"

View File

@ -16,6 +16,7 @@
<artists id="1" name="artist1"></artists> <artists id="1" name="artist1"></artists>
<artists id="2" name="artist2"></artists> <artists id="2" name="artist2"></artists>
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 &amp; artist2" displayAlbumArtist="album artist1 &amp; album artist2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean"> <song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 &amp; artist2" displayAlbumArtist="album artist1 &amp; album artist2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean">
<isrc>ISRC-1</isrc>
<genres name="rock"></genres> <genres name="rock"></genres>
<genres name="progressive"></genres> <genres name="progressive"></genres>
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain> <replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>

View File

@ -30,6 +30,10 @@
"sortName": "sorted title", "sortName": "sorted title",
"mediaType": "song", "mediaType": "song",
"musicBrainzId": "4321", "musicBrainzId": "4321",
"isrc": [
"ISRC-1",
"ISRC-2"
],
"genres": [ "genres": [
{ {
"name": "rock" "name": "rock"

View File

@ -1,6 +1,8 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> <subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<directory id="1" name="N"> <directory id="1" name="N">
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 &amp; artist 2" displayAlbumArtist="album artist 1 &amp; album artist 2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean"> <child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 &amp; artist 2" displayAlbumArtist="album artist 1 &amp; album artist 2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean">
<isrc>ISRC-1</isrc>
<isrc>ISRC-2</isrc>
<genres name="rock"></genres> <genres name="rock"></genres>
<genres name="progressive"></genres> <genres name="progressive"></genres>
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain> <replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>

View File

@ -15,6 +15,7 @@
"sortName": "", "sortName": "",
"mediaType": "", "mediaType": "",
"musicBrainzId": "", "musicBrainzId": "",
"isrc": [],
"genres": [], "genres": [],
"replayGain": {}, "replayGain": {},
"channelCount": 0, "channelCount": 0,

View File

@ -176,6 +176,7 @@ type OpenSubsonicChild struct {
SortName string `xml:"sortName,attr,omitempty" json:"sortName"` SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"` MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"`
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"` MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"` Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"` ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"`
ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"` ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"`
@ -476,10 +477,13 @@ type Shares struct {
} }
type ScanStatus struct { type ScanStatus struct {
Scanning bool `xml:"scanning,attr" json:"scanning"` Scanning bool `xml:"scanning,attr" json:"scanning"`
Count int64 `xml:"count,attr" json:"count"` Count int64 `xml:"count,attr" json:"count"`
FolderCount int64 `xml:"folderCount,attr" json:"folderCount"` FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"` LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"`
Error string `xml:"error,attr,omitempty" json:"error,omitempty"`
ScanType string `xml:"scanType,attr,omitempty" json:"scanType,omitempty"`
ElapsedTime int64 `xml:"elapsedTime,attr,omitempty" json:"elapsedTime,omitempty"`
} }
type Lyrics struct { type Lyrics struct {

View File

@ -224,7 +224,8 @@ var _ = Describe("Responses", func() {
child[0].OpenSubsonicChild = &OpenSubsonicChild{ child[0].OpenSubsonicChild = &OpenSubsonicChild{
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted title", Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted title",
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Isrc: []string{"ISRC-1", "ISRC-2"},
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
Moods: []string{"happy", "sad"}, Moods: []string{"happy", "sad"},
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6}, ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
DisplayArtist: "artist 1 & artist 2", DisplayArtist: "artist 1 & artist 2",
@ -312,6 +313,7 @@ var _ = Describe("Responses", func() {
songs[0].OpenSubsonicChild = &OpenSubsonicChild{ songs[0].OpenSubsonicChild = &OpenSubsonicChild{
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song", Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song",
Isrc: []string{"ISRC-1"},
Moods: []string{"happy", "sad"}, Moods: []string{"happy", "sad"},
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6}, ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,

View File

@ -17,6 +17,7 @@ import FavoriteIcon from '@material-ui/icons/Favorite'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useDrag } from 'react-dnd' import { useDrag } from 'react-dnd'
import clsx from 'clsx'
import { import {
ArtistContextMenu, ArtistContextMenu,
List, List,
@ -49,6 +50,9 @@ const useStyles = makeStyles({
}, },
}, },
}, },
missingRow: {
opacity: 0.3,
},
contextMenu: { contextMenu: {
visibility: 'hidden', visibility: 'hidden',
}, },
@ -95,7 +99,15 @@ const ArtistDatagridRow = (props) => {
}), }),
[record], [record],
) )
return <DatagridRow ref={dragArtistRef} {...props} /> const classes = useStyles()
const computedClasses = clsx(
props.className,
classes.row,
record?.missing && classes.missingRow,
)
return (
<DatagridRow ref={dragArtistRef} {...props} className={computedClasses} />
)
} }
const ArtistDatagridBody = (props) => ( const ArtistDatagridBody = (props) => (

View File

@ -23,7 +23,8 @@ const mapResource = (resource, params) => {
return [`playlist/${plsId}/tracks`, params] return [`playlist/${plsId}/tracks`, params]
} }
case 'album': case 'album':
case 'song': { case 'song':
case 'artist': {
if (params.filter && !isAdmin()) { if (params.filter && !isAdmin()) {
params.filter.missing = false params.filter.missing = false
} }

View File

@ -499,7 +499,10 @@
"quickScan": "Quick Scan", "quickScan": "Quick Scan",
"fullScan": "Full Scan", "fullScan": "Full Scan",
"serverUptime": "Server Uptime", "serverUptime": "Server Uptime",
"serverDown": "OFFLINE" "serverDown": "OFFLINE",
"scanType": "Type",
"status": "Scan Error",
"elapsedTime": "Elapsed Time"
}, },
"help": { "help": {
"title": "Navidrome Hotkeys", "title": "Navidrome Hotkeys",

View File

@ -12,15 +12,16 @@ import {
CardActions, CardActions,
Divider, Divider,
Box, Box,
Typography,
} from '@material-ui/core' } from '@material-ui/core'
import { FiActivity } from 'react-icons/fi' import { FiActivity } from 'react-icons/fi'
import { BiError } from 'react-icons/bi' import { BiError, BiCheckCircle } from 'react-icons/bi'
import { VscSync } from 'react-icons/vsc' import { VscSync } from 'react-icons/vsc'
import { GiMagnifyingGlass } from 'react-icons/gi' import { GiMagnifyingGlass } from 'react-icons/gi'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { scanStatusUpdate } from '../actions' import { scanStatusUpdate } from '../actions'
import { useInterval } from '../common' import { useInterval } from '../common'
import { formatDuration } from '../utils' import { formatDuration, formatShortDuration } from '../utils'
import config from '../config' import config from '../config'
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
@ -40,7 +41,16 @@ const useStyles = makeStyles((theme) => ({
zIndex: 2, zIndex: 2,
}, },
counterStatus: { counterStatus: {
minWidth: '15em', minWidth: '20em',
},
error: {
color: theme.palette.error.main,
},
card: {
maxWidth: 'none',
},
cardContent: {
padding: theme.spacing(2, 3),
}, },
})) }))
@ -59,13 +69,13 @@ const Uptime = () => {
const ActivityPanel = () => { const ActivityPanel = () => {
const serverStart = useSelector((state) => state.activity.serverStart) const serverStart = useSelector((state) => state.activity.serverStart)
const up = serverStart.startTime const up = serverStart.startTime
const classes = useStyles({ up }) const scanStatus = useSelector((state) => state.activity.scanStatus)
const classes = useStyles({ up: up && !scanStatus.error })
const translate = useTranslate() const translate = useTranslate()
const notify = useNotify() const notify = useNotify()
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
const open = Boolean(anchorEl) const open = Boolean(anchorEl)
const dispatch = useDispatch() const dispatch = useDispatch()
const scanStatus = useSelector((state) => state.activity.scanStatus)
const handleMenuOpen = (event) => setAnchorEl(event.currentTarget) const handleMenuOpen = (event) => setAnchorEl(event.currentTarget)
const handleMenuClose = () => setAnchorEl(null) const handleMenuClose = () => setAnchorEl(null)
@ -89,11 +99,30 @@ const ActivityPanel = () => {
} }
}, [serverStart, notify]) }, [serverStart, notify])
const tooltipTitle = scanStatus.error
? `${translate('activity.status')}: ${scanStatus.error}`
: translate('activity.title')
const lastScanType = (() => {
switch (scanStatus.scanType) {
case 'full':
return translate('activity.fullScan')
case 'quick':
return translate('activity.quickScan')
default:
return ''
}
})()
return ( return (
<div className={classes.wrapper}> <div className={classes.wrapper}>
<Tooltip title={translate('activity.title')}> <Tooltip title={tooltipTitle}>
<IconButton className={classes.button} onClick={handleMenuOpen}> <IconButton className={classes.button} onClick={handleMenuOpen}>
{up ? <FiActivity size={'20'} /> : <BiError size={'20'} />} {!up || scanStatus.error ? (
<BiError size={'20'} />
) : (
<FiActivity size={'20'} />
)}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{scanStatus.scanning && ( {scanStatus.scanning && (
@ -113,8 +142,8 @@ const ActivityPanel = () => {
open={open} open={open}
onClose={handleMenuClose} onClose={handleMenuClose}
> >
<Card> <Card className={classes.card}>
<CardContent> <CardContent className={classes.cardContent}>
<Box display="flex" className={classes.counterStatus}> <Box display="flex" className={classes.counterStatus}>
<Box component="span" flex={2}> <Box component="span" flex={2}>
{translate('activity.serverUptime')}: {translate('activity.serverUptime')}:
@ -125,7 +154,7 @@ const ActivityPanel = () => {
</Box> </Box>
</CardContent> </CardContent>
<Divider /> <Divider />
<CardContent> <CardContent className={classes.cardContent}>
<Box display="flex" className={classes.counterStatus}> <Box display="flex" className={classes.counterStatus}>
<Box component="span" flex={2}> <Box component="span" flex={2}>
{translate('activity.totalScanned')}: {translate('activity.totalScanned')}:
@ -134,6 +163,38 @@ const ActivityPanel = () => {
{scanStatus.folderCount || '-'} {scanStatus.folderCount || '-'}
</Box> </Box>
</Box> </Box>
<Box display="flex" className={classes.counterStatus} mt={2}>
<Box component="span" flex={2}>
{translate('activity.scanType')}:
</Box>
<Box component="span" flex={1}>
{lastScanType}
</Box>
</Box>
<Box display="flex" className={classes.counterStatus} mt={2}>
<Box component="span" flex={2}>
{translate('activity.elapsedTime')}:
</Box>
<Box component="span" flex={1}>
{formatShortDuration(scanStatus.elapsedTime)}
</Box>
</Box>
{scanStatus.error && (
<Box
display="flex"
flexDirection="column"
mt={2}
className={classes.error}
>
<Typography variant="subtitle2">
{translate('activity.status')}:
</Typography>
<Typography variant="body2">{scanStatus.error}</Typography>
</Box>
)}
</CardContent> </CardContent>
<Divider /> <Divider />
<CardActions> <CardActions>

View File

@ -6,15 +6,24 @@ import {
import config from '../config' import config from '../config'
const initialState = { const initialState = {
scanStatus: { scanning: false, folderCount: 0, count: 0 }, scanStatus: {
scanning: false,
folderCount: 0,
count: 0,
error: '',
elapsedTime: 0,
},
serverStart: { version: config.version }, serverStart: { version: config.version },
} }
export const activityReducer = (previousState = initialState, payload) => { export const activityReducer = (previousState = initialState, payload) => {
const { type, data } = payload const { type, data } = payload
switch (type) { switch (type) {
case EVENT_SCAN_STATUS: case EVENT_SCAN_STATUS: {
return { ...previousState, scanStatus: data } const elapsedTime = Number(data.elapsedTime) || 0
return { ...previousState, scanStatus: { ...data, elapsedTime } }
}
case EVENT_SERVER_START: case EVENT_SERVER_START:
return { return {
...previousState, ...previousState,

View File

@ -0,0 +1,119 @@
import { activityReducer } from './activityReducer'
import { EVENT_SCAN_STATUS, EVENT_SERVER_START } from '../actions'
import config from '../config'
describe('activityReducer', () => {
const initialState = {
scanStatus: {
scanning: false,
folderCount: 0,
count: 0,
error: '',
elapsedTime: 0,
},
serverStart: { version: config.version },
}
it('returns the initial state when no action is specified', () => {
expect(activityReducer(undefined, {})).toEqual(initialState)
})
it('handles EVENT_SCAN_STATUS action with elapsedTime field', () => {
const elapsedTime = 123456789 // nanoseconds
const action = {
type: EVENT_SCAN_STATUS,
data: {
scanning: true,
folderCount: 5,
count: 100,
error: '',
elapsedTime: elapsedTime,
},
}
const newState = activityReducer(initialState, action)
expect(newState.scanStatus).toEqual({
scanning: true,
folderCount: 5,
count: 100,
error: '',
elapsedTime: elapsedTime,
})
})
it('handles EVENT_SCAN_STATUS action with string elapsedTime', () => {
const action = {
type: EVENT_SCAN_STATUS,
data: {
scanning: true,
folderCount: 5,
count: 100,
error: '',
elapsedTime: '123456789',
},
}
const newState = activityReducer(initialState, action)
expect(newState.scanStatus.elapsedTime).toEqual(123456789)
})
it('handles EVENT_SCAN_STATUS with error field', () => {
const action = {
type: EVENT_SCAN_STATUS,
data: {
scanning: false,
folderCount: 0,
count: 0,
error: 'Test error message',
elapsedTime: 0,
},
}
const newState = activityReducer(initialState, action)
expect(newState.scanStatus.error).toEqual('Test error message')
})
it('handles EVENT_SERVER_START action', () => {
const action = {
type: EVENT_SERVER_START,
data: {
version: '1.0.0',
startTime: '2023-01-01T00:00:00Z',
},
}
const newState = activityReducer(initialState, action)
expect(newState.serverStart).toEqual({
version: '1.0.0',
startTime: Date.parse('2023-01-01T00:00:00Z'),
})
})
it('preserves the scanStatus when handling EVENT_SERVER_START', () => {
const currentState = {
scanStatus: {
scanning: true,
folderCount: 5,
count: 100,
error: 'Previous error',
elapsedTime: 12345,
},
serverStart: { version: config.version },
}
const action = {
type: EVENT_SERVER_START,
data: {
version: '1.0.0',
startTime: '2023-01-01T00:00:00Z',
},
}
const newState = activityReducer(currentState, action)
expect(newState.scanStatus).toEqual(currentState.scanStatus)
expect(newState.serverStart).toEqual({
version: '1.0.0',
startTime: Date.parse('2023-01-01T00:00:00Z'),
})
})
})

View File

@ -25,6 +25,26 @@ export const formatDuration = (d) => {
return `${days > 0 ? days + ':' : ''}${f}` return `${days > 0 ? days + ':' : ''}${f}`
} }
export const formatShortDuration = (ns) => {
// Convert nanoseconds to seconds
const seconds = ns / 1e9
if (seconds < 1.0) {
return '<1s'
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}h${minutes}m`
}
if (minutes > 0) {
return `${minutes}m${secs}s`
}
return `${secs}s`
}
export const formatFullDate = (date, locale) => { export const formatFullDate = (date, locale) => {
const dashes = date.split('-').length - 1 const dashes = date.split('-').length - 1
let options = { let options = {

View File

@ -1,4 +1,9 @@
import { formatBytes, formatDuration, formatFullDate } from './formatters' import {
formatBytes,
formatDuration,
formatFullDate,
formatShortDuration,
} from './formatters'
describe('formatBytes', () => { describe('formatBytes', () => {
it('format bytes', () => { it('format bytes', () => {
@ -32,6 +37,33 @@ describe('formatDuration', () => {
}) })
}) })
describe('formatShortDuration', () => {
// Convert seconds to nanoseconds for the tests
const toNs = (seconds) => seconds * 1e9
it('formats less than a second', () => {
expect(formatShortDuration(toNs(0.5))).toEqual('<1s')
expect(formatShortDuration(toNs(0))).toEqual('<1s')
})
it('formats seconds', () => {
expect(formatShortDuration(toNs(1))).toEqual('1s')
expect(formatShortDuration(toNs(59))).toEqual('59s')
})
it('formats minutes and seconds', () => {
expect(formatShortDuration(toNs(60))).toEqual('1m0s')
expect(formatShortDuration(toNs(90))).toEqual('1m30s')
expect(formatShortDuration(toNs(59 * 60 + 59))).toEqual('59m59s')
})
it('formats hours and minutes', () => {
expect(formatShortDuration(toNs(3600))).toEqual('1h0m')
expect(formatShortDuration(toNs(3600 + 30 * 60))).toEqual('1h30m')
expect(formatShortDuration(toNs(24 * 3600 - 1))).toEqual('23h59m')
})
})
describe('formatFullDate', () => { describe('formatFullDate', () => {
it('format dates', () => { it('format dates', () => {
expect(formatFullDate('2011', 'en-US')).toEqual('2011') expect(formatFullDate('2011', 'en-US')).toEqual('2011')