mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge branch 'master' into msi-insights-detection
This commit is contained in:
commit
114f870f02
4
.gitignore
vendored
4
.gitignore
vendored
@ -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/
|
||||||
8
Makefile
8
Makefile
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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() }),
|
||||||
|
|||||||
@ -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) })
|
||||||
|
|||||||
57
scanner/controller_test.go
Normal file
57
scanner/controller_test.go
Normal 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"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & 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 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & 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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & 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 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & 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>
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
"sortName": "",
|
"sortName": "",
|
||||||
"mediaType": "",
|
"mediaType": "",
|
||||||
"musicBrainzId": "",
|
"musicBrainzId": "",
|
||||||
|
"isrc": [],
|
||||||
"genres": [],
|
"genres": [],
|
||||||
"replayGain": {},
|
"replayGain": {},
|
||||||
"channelCount": 0,
|
"channelCount": 0,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
119
ui/src/reducers/activityReducer.test.js
Normal file
119
ui/src/reducers/activityReducer.test.js
Normal 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'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 = {
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user