diff --git a/.gitignore b/.gitignore
index 27b23240f..4e32e14fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,6 @@ docker-compose.yml
!contrib/docker-compose.yml
binaries
navidrome-master
-*.exe
\ No newline at end of file
+AGENTS.md
+*.exe
+bin/
\ No newline at end of file
diff --git a/Makefile b/Makefile
index f29c27981..38f1ca9ed 100644
--- a/Makefile
+++ b/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
mkdir -p 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=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=e49c609b542fc51899ee8b53aa858cb4" > 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=2Y3qQA6zJC3ObbBrF9ZBoV" > brock.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=5xcMPJdeEgNrGtnzYbzAqb" > ugress.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 )
@echo "Done. Remember to set your MusicFolder to ./music"
.PHONY: get-music
diff --git a/conf/configuration.go b/conf/configuration.go
index 1223ffedf..eebf1c004 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -72,6 +72,7 @@ type configOptions struct {
EnableUserEditing bool
EnableSharing bool
ShareURL string
+ DefaultShareExpiration time.Duration
DefaultDownloadableShare bool
DefaultTheme string
DefaultLanguage string
@@ -472,6 +473,7 @@ func init() {
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
+ viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
viper.SetDefault("defaultdownloadableshare", false)
viper.SetDefault("gatrackingid", "")
viper.SetDefault("enableinsightscollector", true)
diff --git a/consts/consts.go b/consts/consts.go
index 75271bec8..2dbb46d07 100644
--- a/consts/consts.go
+++ b/consts/consts.go
@@ -14,6 +14,9 @@ const (
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
InitialSetupFlagKey = "InitialSetup"
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
+ LastScanErrorKey = "LastScanError"
+ LastScanTypeKey = "LastScanType"
+ LastScanStartTimeKey = "LastScanStartTime"
UIAuthorizationHeader = "X-ND-Authorization"
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
diff --git a/core/share.go b/core/share.go
index e6035ab82..add88322d 100644
--- a/core/share.go
+++ b/core/share.go
@@ -8,6 +8,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
gonanoid "github.com/matoous/go-nanoid/v2"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
@@ -93,7 +94,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
}
s.ID = id
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]
diff --git a/model/artist.go b/model/artist.go
index 9c83150bd..68836ff28 100644
--- a/model/artist.go
+++ b/model/artist.go
@@ -32,6 +32,8 @@ type Artist struct {
SimilarArtists Artists `structs:"similar_artists" json:"-"`
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"`
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
}
@@ -76,7 +78,7 @@ type ArtistRepository interface {
UpdateExternalInfo(a *Artist) error
Get(id string) (*Artist, 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:
RefreshPlayCounts() (int64, error)
diff --git a/model/tag.go b/model/tag.go
index a9864e0bf..a1f4e28da 100644
--- a/model/tag.go
+++ b/model/tag.go
@@ -191,6 +191,7 @@ const (
TagReleaseCountry TagName = "releasecountry"
TagMedia TagName = "media"
TagCatalogNumber TagName = "catalognumber"
+ TagISRC TagName = "isrc"
TagBPM TagName = "bpm"
TagExplicitStatus TagName = "explicitstatus"
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index ecb8f8bf6..b6b1aba44 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -116,6 +116,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
"name": fullTextFilter(r.tableName),
"starred": booleanFilter,
"role": roleFilter,
+ "missing": booleanFilter,
})
r.setSortMappings(map[string]string{
"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)
-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"}
if len(roles) > 0 {
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)
}
+ 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)
if err != nil {
return nil, err
@@ -236,6 +244,26 @@ func (r *artistRepository) purgeEmpty() error {
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
// on the media files associated with them.
func (r *artistRepository) RefreshPlayCounts() (int64, error) {
diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go
index f9e58d216..0c7018dc8 100644
--- a/persistence/artist_repository_test.go
+++ b/persistence/artist_repository_test.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
+ "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
@@ -94,7 +95,7 @@ var _ = Describe("ArtistRepository", func() {
er := repo.Put(&artistBeatles)
Expect(er).To(BeNil())
- idx, err := repo.GetIndex()
+ idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
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
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(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
@@ -134,7 +135,7 @@ var _ = Describe("ArtistRepository", func() {
er := repo.Put(&artistBeatles)
Expect(er).To(BeNil())
- idx, err := repo.GetIndex()
+ idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
@@ -151,7 +152,7 @@ var _ = Describe("ArtistRepository", func() {
})
It("returns the index when SortArtistName is empty", func() {
- idx, err := repo.GetIndex()
+ idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
@@ -233,5 +234,91 @@ var _ = Describe("ArtistRepository", func() {
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))
+ })
+ })
+ })
})
})
diff --git a/persistence/persistence.go b/persistence/persistence.go
index 579f13707..31e03db61 100644
--- a/persistence/persistence.go
+++ b/persistence/persistence.go
@@ -170,6 +170,7 @@ func (s *SQLStore) GC(ctx context.Context) error {
err := chain.RunSequentially(
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, "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, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
diff --git a/scanner/controller.go b/scanner/controller.go
index e3e008483..36048e6f3 100644
--- a/scanner/controller.go
+++ b/scanner/controller.go
@@ -9,6 +9,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
@@ -37,6 +38,9 @@ type StatusInfo struct {
LastScan time.Time
Count uint32
FolderCount uint32
+ LastError string
+ ScanType string
+ ElapsedTime time.Duration
}
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
@@ -113,20 +117,51 @@ type controller struct {
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) {
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
if err != nil {
return nil, fmt.Errorf("getting library: %w", err)
}
+
+ scanType, elapsed, lastErr := s.getScanInfo(ctx)
+
if running.Load() {
status := &StatusInfo{
Scanning: true,
LastScan: lib.LastScanAt,
Count: s.count.Load(),
FolderCount: s.folderCount.Load(),
+ LastError: lastErr,
+ ScanType: scanType,
+ ElapsedTime: elapsed,
}
return status, nil
}
+
count, folderCount, err := s.getCounters(ctx)
if err != nil {
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,
Count: uint32(count),
FolderCount: uint32(folderCount),
+ LastError: lastErr,
+ ScanType: scanType,
+ ElapsedTime: elapsed,
}, nil
}
@@ -193,10 +231,14 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
if count, folderCount, err := s.getCounters(ctx); err != nil {
return scanWarnings, err
} else {
+ scanType, elapsed, lastErr := s.getScanInfo(ctx)
s.sendMessage(ctx, &events.ScanStatus{
Scanning: false,
Count: count,
FolderCount: folderCount,
+ Error: lastErr,
+ ScanType: scanType,
+ ElapsedTime: elapsed,
})
}
return scanWarnings, scanError
@@ -240,10 +282,15 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
if p.FileCount > 0 {
s.folderCount.Add(1)
}
+
+ scanType, elapsed, lastErr := s.getScanInfo(ctx)
status := &events.ScanStatus{
Scanning: true,
Count: int64(s.count.Load()),
FolderCount: int64(s.folderCount.Load()),
+ Error: lastErr,
+ ScanType: scanType,
+ ElapsedTime: elapsed,
}
if s.limiter != nil {
s.limiter.Do(func() { s.sendMessage(ctx, status) })
diff --git a/scanner/controller_test.go b/scanner/controller_test.go
new file mode 100644
index 000000000..4f6576a39
--- /dev/null
+++ b/scanner/controller_test.go
@@ -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"))
+ })
+ })
+})
diff --git a/scanner/scanner.go b/scanner/scanner.go
index 1c08e3fb3..21de5f956 100644
--- a/scanner/scanner.go
+++ b/scanner/scanner.go
@@ -57,12 +57,21 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
startTime := time.Now()
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 !state.fullScan {
for _, lib := range libs {
if lib.FullScanInProgress {
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
state.fullScan = true
+ _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
break
}
}
@@ -100,11 +109,14 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
)
if err != nil {
log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err)
+ _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error())
state.sendError(err)
s.metrics.WriteAfterScanMetrics(ctx, false)
return
}
+ _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, "")
+
if state.changesDetected.Load() {
state.sendProgress(&ProgressInfo{ChangesDetected: true})
}
diff --git a/server/events/events.go b/server/events/events.go
index 38b906f2a..73ff8eb5e 100644
--- a/server/events/events.go
+++ b/server/events/events.go
@@ -37,9 +37,12 @@ func (e *baseEvent) Data(evt Event) string {
type ScanStatus struct {
baseEvent
- Scanning bool `json:"scanning"`
- Count int64 `json:"count"`
- FolderCount int64 `json:"folderCount"`
+ Scanning bool `json:"scanning"`
+ Count int64 `json:"count"`
+ FolderCount int64 `json:"folderCount"`
+ Error string `json:"error"`
+ ScanType string `json:"scanType"`
+ ElapsedTime time.Duration `json:"elapsedTime"`
}
type KeepAlive struct {
diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go
index c00a9f1ab..76023c862 100644
--- a/server/subsonic/browsing.go
+++ b/server/subsonic/browsing.go
@@ -38,7 +38,7 @@ func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Ti
var indexes model.ArtistIndexes
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 {
log.Error(ctx, "Error retrieving Indexes", err)
return nil, 0, err
diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go
index ab285e154..4ab4f9642 100644
--- a/server/subsonic/filter/filters.go
+++ b/server/subsonic/filter/filters.go
@@ -108,12 +108,19 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
return addDefaultFilters(options)
}
-func SongWithArtistTitle(artist, title string) Options {
+func SongWithLyrics(artist, title string) Options {
return addDefaultFilters(Options{
- Sort: "updated_at",
- Order: "desc",
- Max: 1,
- Filters: And{Eq{"artist": artist, "title": title}},
+ Sort: "updated_at",
+ Order: "desc",
+ Max: 1,
+ 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}),
+ },
+ },
})
}
diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go
index 4faec158f..39f324654 100644
--- a/server/subsonic/helpers.go
+++ b/server/subsonic/helpers.go
@@ -224,6 +224,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op
child.BPM = int32(mf.BPM)
child.MediaType = responses.MediaTypeSong
child.MusicBrainzId = mf.MbzRecordingID
+ child.Isrc = mf.Tags.Values(model.TagISRC)
child.ReplayGain = responses.ReplayGain{
TrackGain: mf.RGTrackGain,
AlbumGain: mf.RGAlbumGain,
diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go
index a25955ea7..b6ccb9ae6 100644
--- a/server/subsonic/library_scanning.go
+++ b/server/subsonic/library_scanning.go
@@ -23,6 +23,9 @@ func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) {
Count: int64(status.Count),
FolderCount: int64(status.FolderCount),
LastScan: &status.LastScan,
+ Error: status.LastError,
+ ScanType: status.ScanType,
+ ElapsedTime: int64(status.ElapsedTime),
}
return response, nil
}
diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go
index 35a3fd3d3..5cca74c30 100644
--- a/server/subsonic/media_retrieval.go
+++ b/server/subsonic/media_retrieval.go
@@ -98,7 +98,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
response := newResponse()
lyricsResponse := responses.Lyrics{}
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 {
return nil, err
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON
index 0db35c37c..0e6425f6a 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON
+++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON
@@ -15,6 +15,7 @@
"sortName": "sort name",
"mediaType": "album",
"musicBrainzId": "00000000-0000-0000-0000-000000000000",
+ "isrc": [],
"genres": [
{
"name": "Genre 1"
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON
index c3ae3ee20..78b5c6e7a 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON
+++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON
@@ -99,6 +99,9 @@
"sortName": "sorted song",
"mediaType": "song",
"musicBrainzId": "4321",
+ "isrc": [
+ "ISRC-1"
+ ],
"genres": [
{
"name": "rock"
diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML
index a02c0feee..f3281d9ee 100644
--- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML
+++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML
@@ -16,6 +16,7 @@
+ ISRC-1
diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON
index 13aa1f187..d64ae9e7f 100644
--- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON
+++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON
@@ -30,6 +30,10 @@
"sortName": "sorted title",
"mediaType": "song",
"musicBrainzId": "4321",
+ "isrc": [
+ "ISRC-1",
+ "ISRC-2"
+ ],
"genres": [
{
"name": "rock"
diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML
index 477892ac7..639fd3f60 100644
--- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML
+++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML
@@ -1,6 +1,8 @@
+ ISRC-1
+ ISRC-2
diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON
index 5dc0e8eb8..1af2ec4a1 100644
--- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON
+++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON
@@ -15,6 +15,7 @@
"sortName": "",
"mediaType": "",
"musicBrainzId": "",
+ "isrc": [],
"genres": [],
"replayGain": {},
"channelCount": 0,
diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go
index 0d22ef50b..4a7ebbe83 100644
--- a/server/subsonic/responses/responses.go
+++ b/server/subsonic/responses/responses.go
@@ -176,6 +176,7 @@ type OpenSubsonicChild struct {
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"`
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
+ Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"`
ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"`
@@ -476,10 +477,13 @@ type Shares struct {
}
type ScanStatus struct {
- Scanning bool `xml:"scanning,attr" json:"scanning"`
- Count int64 `xml:"count,attr" json:"count"`
- FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
- LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"`
+ Scanning bool `xml:"scanning,attr" json:"scanning"`
+ Count int64 `xml:"count,attr" json:"count"`
+ FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
+ 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 {
diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go
index e484ab2c2..9fcd6078e 100644
--- a/server/subsonic/responses/responses_test.go
+++ b/server/subsonic/responses/responses_test.go
@@ -224,7 +224,8 @@ var _ = Describe("Responses", func() {
child[0].OpenSubsonicChild = &OpenSubsonicChild{
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
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"},
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
DisplayArtist: "artist 1 & artist 2",
@@ -312,6 +313,7 @@ var _ = Describe("Responses", func() {
songs[0].OpenSubsonicChild = &OpenSubsonicChild{
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song",
+ Isrc: []string{"ISRC-1"},
Moods: []string{"happy", "sad"},
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx
index d3fc4ceee..f26cff217 100644
--- a/ui/src/artist/ArtistList.jsx
+++ b/ui/src/artist/ArtistList.jsx
@@ -17,6 +17,7 @@ import FavoriteIcon from '@material-ui/icons/Favorite'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import { makeStyles } from '@material-ui/core/styles'
import { useDrag } from 'react-dnd'
+import clsx from 'clsx'
import {
ArtistContextMenu,
List,
@@ -49,6 +50,9 @@ const useStyles = makeStyles({
},
},
},
+ missingRow: {
+ opacity: 0.3,
+ },
contextMenu: {
visibility: 'hidden',
},
@@ -95,7 +99,15 @@ const ArtistDatagridRow = (props) => {
}),
[record],
)
- return
+ const classes = useStyles()
+ const computedClasses = clsx(
+ props.className,
+ classes.row,
+ record?.missing && classes.missingRow,
+ )
+ return (
+
+ )
}
const ArtistDatagridBody = (props) => (
diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js
index 1e3321255..f387136cb 100644
--- a/ui/src/dataProvider/wrapperDataProvider.js
+++ b/ui/src/dataProvider/wrapperDataProvider.js
@@ -23,7 +23,8 @@ const mapResource = (resource, params) => {
return [`playlist/${plsId}/tracks`, params]
}
case 'album':
- case 'song': {
+ case 'song':
+ case 'artist': {
if (params.filter && !isAdmin()) {
params.filter.missing = false
}
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 76c4d5190..5ccebf115 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -499,7 +499,10 @@
"quickScan": "Quick Scan",
"fullScan": "Full Scan",
"serverUptime": "Server Uptime",
- "serverDown": "OFFLINE"
+ "serverDown": "OFFLINE",
+ "scanType": "Type",
+ "status": "Scan Error",
+ "elapsedTime": "Elapsed Time"
},
"help": {
"title": "Navidrome Hotkeys",
diff --git a/ui/src/layout/ActivityPanel.jsx b/ui/src/layout/ActivityPanel.jsx
index 840c54b6f..f7371bc2f 100644
--- a/ui/src/layout/ActivityPanel.jsx
+++ b/ui/src/layout/ActivityPanel.jsx
@@ -12,15 +12,16 @@ import {
CardActions,
Divider,
Box,
+ Typography,
} from '@material-ui/core'
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 { GiMagnifyingGlass } from 'react-icons/gi'
import subsonic from '../subsonic'
import { scanStatusUpdate } from '../actions'
import { useInterval } from '../common'
-import { formatDuration } from '../utils'
+import { formatDuration, formatShortDuration } from '../utils'
import config from '../config'
const useStyles = makeStyles((theme) => ({
@@ -40,7 +41,16 @@ const useStyles = makeStyles((theme) => ({
zIndex: 2,
},
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 serverStart = useSelector((state) => state.activity.serverStart)
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 notify = useNotify()
const [anchorEl, setAnchorEl] = useState(null)
const open = Boolean(anchorEl)
const dispatch = useDispatch()
- const scanStatus = useSelector((state) => state.activity.scanStatus)
const handleMenuOpen = (event) => setAnchorEl(event.currentTarget)
const handleMenuClose = () => setAnchorEl(null)
@@ -89,11 +99,30 @@ const ActivityPanel = () => {
}
}, [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 (
-
+
- {up ? : }
+ {!up || scanStatus.error ? (
+
+ ) : (
+
+ )}
{scanStatus.scanning && (
@@ -113,8 +142,8 @@ const ActivityPanel = () => {
open={open}
onClose={handleMenuClose}
>
-
-
+
+
{translate('activity.serverUptime')}:
@@ -125,7 +154,7 @@ const ActivityPanel = () => {
-
+
{translate('activity.totalScanned')}:
@@ -134,6 +163,38 @@ const ActivityPanel = () => {
{scanStatus.folderCount || '-'}
+
+
+
+ {translate('activity.scanType')}:
+
+
+ {lastScanType}
+
+
+
+
+
+ {translate('activity.elapsedTime')}:
+
+
+ {formatShortDuration(scanStatus.elapsedTime)}
+
+
+
+ {scanStatus.error && (
+
+
+ {translate('activity.status')}:
+
+ {scanStatus.error}
+
+ )}
diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js
index d2a7d310b..2b6d2741c 100644
--- a/ui/src/reducers/activityReducer.js
+++ b/ui/src/reducers/activityReducer.js
@@ -6,15 +6,24 @@ import {
import config from '../config'
const initialState = {
- scanStatus: { scanning: false, folderCount: 0, count: 0 },
+ scanStatus: {
+ scanning: false,
+ folderCount: 0,
+ count: 0,
+ error: '',
+ elapsedTime: 0,
+ },
serverStart: { version: config.version },
}
export const activityReducer = (previousState = initialState, payload) => {
const { type, data } = payload
+
switch (type) {
- case EVENT_SCAN_STATUS:
- return { ...previousState, scanStatus: data }
+ case EVENT_SCAN_STATUS: {
+ const elapsedTime = Number(data.elapsedTime) || 0
+ return { ...previousState, scanStatus: { ...data, elapsedTime } }
+ }
case EVENT_SERVER_START:
return {
...previousState,
diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js
new file mode 100644
index 000000000..a1389e3d2
--- /dev/null
+++ b/ui/src/reducers/activityReducer.test.js
@@ -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'),
+ })
+ })
+})
diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js
index 25daa33d4..ae27f230f 100644
--- a/ui/src/utils/formatters.js
+++ b/ui/src/utils/formatters.js
@@ -25,6 +25,26 @@ export const formatDuration = (d) => {
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) => {
const dashes = date.split('-').length - 1
let options = {
diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js
index 59538ec32..87b40f16b 100644
--- a/ui/src/utils/formatters.test.js
+++ b/ui/src/utils/formatters.test.js
@@ -1,4 +1,9 @@
-import { formatBytes, formatDuration, formatFullDate } from './formatters'
+import {
+ formatBytes,
+ formatDuration,
+ formatFullDate,
+ formatShortDuration,
+} from './formatters'
describe('formatBytes', () => {
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', () => {
it('format dates', () => {
expect(formatFullDate('2011', 'en-US')).toEqual('2011')