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')