From 2867cebd55b42f44b0e814de7c0acf6879b5da6a Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 7 Jun 2025 12:42:16 -0400 Subject: [PATCH 001/275] fix(scanner): normalize attribute strings and add edge case tests for PID calculation Relates to https://github.com/navidrome/navidrome/issues/4183#issuecomment-2952729458 Signed-off-by: Deluan --- model/metadata/persistent_ids.go | 1 + model/metadata/persistent_ids_test.go | 33 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index a71749e81..0a1451cfb 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -24,6 +24,7 @@ type hashFunc = func(...string) string func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec string) string { var getPID func(mf model.MediaFile, md Metadata, spec string) string getAttr := func(mf model.MediaFile, md Metadata, attr string) string { + attr = strings.TrimSpace(strings.ToLower(attr)) switch attr { case "albumid": return getPID(mf, md, conf.Server.PID.Album) diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index 6903abc05..d07b36331 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -61,6 +61,7 @@ var _ = Describe("getPID", func() { }) }) }) + Context("calculated attributes", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -114,4 +115,36 @@ var _ = Describe("getPID", func() { }) }) }) + + Context("edge cases", func() { + When("the spec has spaces between groups", func() { + It("should return the pid", func() { + spec := "albumartist| Album" + md.tags = map[model.TagName][]string{ + "album": {"album name"}, + } + Expect(getPID(mf, md, spec)).To(Equal("(album name)")) + }) + }) + When("the spec has spaces", func() { + It("should return the pid", func() { + spec := "albumartist, album" + md.tags = map[model.TagName][]string{ + "albumartist": {"Album Artist"}, + "album": {"album name"}, + } + Expect(getPID(mf, md, spec)).To(Equal("(Album Artist\\album name)")) + }) + }) + When("the spec has mixed case fields", func() { + It("should return the pid", func() { + spec := "albumartist,Album" + md.tags = map[model.TagName][]string{ + "albumartist": {"Album Artist"}, + "album": {"album name"}, + } + Expect(getPID(mf, md, spec)).To(Equal("(Album Artist\\album name)")) + }) + }) + }) }) From 844966df89942e6a8efef493e37456b2bf8f51ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 7 Jun 2025 23:11:13 -0400 Subject: [PATCH 002/275] test(ui): fix warnings (#4187) * fix(ui): address test warnings * ignore lint error in test Signed-off-by: Deluan --------- Signed-off-by: Deluan --- ui/src/audioplayer/AudioTitle.test.jsx | 7 ++++--- ui/src/dialogs/SaveQueueDialog.jsx | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ui/src/audioplayer/AudioTitle.test.jsx b/ui/src/audioplayer/AudioTitle.test.jsx index c3f566f6b..7b297c07e 100644 --- a/ui/src/audioplayer/AudioTitle.test.jsx +++ b/ui/src/audioplayer/AudioTitle.test.jsx @@ -12,11 +12,12 @@ vi.mock('@material-ui/core', async () => { }) vi.mock('react-router-dom', () => ({ - Link: ({ to, children, ...props }) => ( - + // eslint-disable-next-line react/display-name + Link: React.forwardRef(({ to, children, ...props }, ref) => ( + {children} - ), + )), })) vi.mock('react-dnd', () => ({ diff --git a/ui/src/dialogs/SaveQueueDialog.jsx b/ui/src/dialogs/SaveQueueDialog.jsx index 69f07dab7..f916a0793 100644 --- a/ui/src/dialogs/SaveQueueDialog.jsx +++ b/ui/src/dialogs/SaveQueueDialog.jsx @@ -57,7 +57,10 @@ export const SaveQueueDialog = () => { return res }) .then((res) => { - notify('ra.notification.created', 'info', { smart_count: 1 }) + notify('ra.notification.created', { + type: 'info', + messageArgs: { smart_count: 1 }, + }) dispatch(closeSaveQueueDialog()) refresh() history.push(`/playlist/${res.data.id}/show`) From bc733540f9860c1d20187473961cdc7057e24877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 8 Jun 2025 11:44:44 -0400 Subject: [PATCH 003/275] refactor(server): optimize top songs lookup (#4189) * optimize top songs lookup * Optimize title matching queries * refactor: simplify top songs matching * improve error handling and logging in track loading functions Signed-off-by: Deluan * test: add cases for fallback to title matching and combined MBID/title matching Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/external/provider.go | 121 +++++++++++++++----- core/external/provider_similarsongs_test.go | 22 ++-- core/external/provider_topsongs_test.go | 72 ++++++++++-- 3 files changed, 166 insertions(+), 49 deletions(-) diff --git a/core/external/provider.go b/core/external/provider.go index f27ded11b..c23d1edd7 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -3,6 +3,7 @@ package external import ( "context" "errors" + "fmt" "net/url" "sort" "strings" @@ -400,20 +401,21 @@ func (e *provider) TopSongs(ctx context.Context, artistName string, count int) ( func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) { songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artist.Name, err) } - var mfs model.MediaFiles - for _, t := range songs { - mf, err := e.findMatchingTrack(ctx, t.MBID, artist.ID, t.Name) - if err != nil { - continue - } - mfs = append(mfs, *mf) - if len(mfs) == count { - break - } + mbidMatches, err := e.loadTracksByMBID(ctx, songs) + if err != nil { + return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) } + titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, mbidMatches) + if err != nil { + return nil, fmt.Errorf("failed to load tracks by title: %w", err) + } + + log.Trace(ctx, "Top Songs loaded", "name", artist.Name, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches)) + mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count) + if len(mfs) == 0 { log.Debug(ctx, "No matching top songs found", "name", artist.Name) } else { @@ -423,35 +425,94 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return mfs, nil } -func (e *provider) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) { - if mbid != "" { - mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"mbz_recording_id": mbid}, - squirrel.Eq{"missing": false}, - }, - }) - if err == nil && len(mfs) > 0 { - return &mfs[0], nil +func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { + var mbids []string + for _, s := range songs { + if s.MBID != "" { + mbids = append(mbids, s.MBID) } - return e.findMatchingTrack(ctx, "", artistID, title) } - mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + matches := map[string]model.MediaFile{} + if len(mbids) == 0 { + return matches, nil + } + res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"mbz_recording_id": mbids}, + squirrel.Eq{"missing": false}, + }, + }) + if err != nil { + return matches, err + } + for _, mf := range res { + if id := mf.MbzRecordingID; id != "" { + if _, ok := matches[id]; !ok { + matches[id] = mf + } + } + } + return matches, nil +} + +func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) { + titleMap := map[string]string{} + for _, s := range songs { + if s.MBID != "" && mbidMatches[s.MBID].ID != "" { + continue + } + sanitized := str.SanitizeFieldForSorting(s.Name) + titleMap[sanitized] = s.Name + } + matches := map[string]model.MediaFile{} + if len(titleMap) == 0 { + return matches, nil + } + titleFilters := squirrel.Or{} + for sanitized := range titleMap { + titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized}) + } + + res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Or{ - squirrel.Eq{"artist_id": artistID}, - squirrel.Eq{"album_artist_id": artistID}, + squirrel.Eq{"artist_id": artist.ID}, + squirrel.Eq{"album_artist_id": artist.ID}, }, - squirrel.Like{"order_title": str.SanitizeFieldForSorting(title)}, + titleFilters, squirrel.Eq{"missing": false}, }, Sort: "starred desc, rating desc, year asc, compilation asc ", - Max: 1, }) - if err != nil || len(mfs) == 0 { - return nil, model.ErrNotFound + if err != nil { + return matches, err } - return &mfs[0], nil + for _, mf := range res { + sanitized := str.SanitizeFieldForSorting(mf.Title) + if _, ok := matches[sanitized]; !ok { + matches[sanitized] = mf + } + } + return matches, nil +} + +func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles { + var mfs model.MediaFiles + for _, t := range songs { + if len(mfs) == count { + break + } + if t.MBID != "" { + if mf, ok := byMBID[t.MBID]; ok { + mfs = append(mfs, mf) + continue + } + } + if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok { + mfs = append(mfs, mf) + } + } + return mfs } func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index fd622746a..e7b3cee1f 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -50,9 +50,9 @@ var _ = Describe("Provider - SimilarSongs", func() { It("returns similar songs from main artist and similar artists", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"} - song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} + song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"} artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() @@ -82,9 +82,8 @@ var _ = Describe("Provider - SimilarSongs", func() { {Name: "Song Three", MBID: "mbid-3"}, }, nil).Once() - mediaFileRepo.FindByMBID("mbid-1", song1) - mediaFileRepo.FindByMBID("mbid-2", song2) - mediaFileRepo.FindByMBID("mbid-3", song3) + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once() songs, err := provider.SimilarSongs(ctx, "artist-1", 3) @@ -111,7 +110,7 @@ var _ = Describe("Provider - SimilarSongs", func() { It("returns songs from main artist when GetSimilarArtists returns error", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { @@ -130,7 +129,7 @@ var _ = Describe("Provider - SimilarSongs", func() { {Name: "Song One", MBID: "mbid-1"}, }, nil).Once() - mediaFileRepo.FindByMBID("mbid-1", song1) + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() songs, err := provider.SimilarSongs(ctx, "artist-1", 5) @@ -165,8 +164,8 @@ var _ = Describe("Provider - SimilarSongs", func() { It("respects count parameter", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { @@ -186,8 +185,7 @@ var _ = Describe("Provider - SimilarSongs", func() { {Name: "Song Two", MBID: "mbid-2"}, }, nil).Once() - mediaFileRepo.FindByMBID("mbid-1", song1) - mediaFileRepo.FindByMBID("mbid-2", song2) + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() songs, err := provider.SimilarSongs(ctx, "artist-1", 1) diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 4ce7911de..443be36dd 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -58,11 +58,10 @@ var _ = Describe("Provider - TopSongs", func() { } ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - // Mock finding matching tracks + // Mock finding matching tracks (both returned in a single query) song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -155,11 +154,10 @@ var _ = Describe("Provider - TopSongs", func() { } ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - // Mock finding matching tracks (only find song 1) + // Mock finding matching tracks (only find song 1 on bulk query) song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For mbid-song-2 (fails) - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For title fallback (fails) + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2 songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -190,4 +188,64 @@ var _ = Describe("Provider - TopSongs", func() { artistRepo.AssertExpectations(GinkgoT()) ag.AssertExpectations(GinkgoT()) }) + + It("falls back to title matching when MbzRecordingID is missing", func() { + // Mock finding the artist + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + + // Mock agent response with songs that have NO MBID (empty string) + agentSongs := []agents.Song{ + {Name: "Song One", MBID: ""}, // No MBID, should fall back to title matching + {Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() + + // Since there are no MBIDs, loadTracksByMBID should not make any database call + // loadTracksByTitle should make a database call for title matching + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() + + songs, err := p.TopSongs(ctx, "Artist One", 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(2)) + Expect(songs[0].ID).To(Equal("song-1")) + Expect(songs[1].ID).To(Equal("song-2")) + artistRepo.AssertExpectations(GinkgoT()) + ag.AssertExpectations(GinkgoT()) + mediaFileRepo.AssertExpectations(GinkgoT()) + }) + + It("combines MBID and title matching when some songs have missing MbzRecordingID", func() { + // Mock finding the artist + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + + // Mock agent response with mixed MBID availability + agentSongs := []agents.Song{ + {Name: "Song One", MBID: "mbid-song-1"}, // Has MBID, should match by MBID + {Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() + + // Mock the MBID query (finds song1 by MBID) + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"} + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() + + // Mock the title fallback query (finds song2 by title) + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() + + songs, err := p.TopSongs(ctx, "Artist One", 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(2)) + Expect(songs[0].ID).To(Equal("song-1")) // Found by MBID + Expect(songs[1].ID).To(Equal("song-2")) // Found by title + artistRepo.AssertExpectations(GinkgoT()) + ag.AssertExpectations(GinkgoT()) + mediaFileRepo.AssertExpectations(GinkgoT()) + }) }) From 7d1f5ddf0690eb8cf7ad00d077bbe3781e9cd3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 8 Jun 2025 14:21:40 -0400 Subject: [PATCH 004/275] fix(ui): playlist details overflow in spotify-based themes (#4184) * test: ensure playlist details width * fix(test): simplify expectation for minWidth in NDPlaylistDetails Signed-off-by: Deluan * fix(test): test all themes Signed-off-by: Deluan --------- Signed-off-by: Deluan --- ui/src/themes/nord.js | 1 - ui/src/themes/spotify.js | 1 - ui/src/themes/theme.test.js | 14 ++++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 ui/src/themes/theme.test.js diff --git a/ui/src/themes/nord.js b/ui/src/themes/nord.js index 8c346eefe..5420bbc60 100644 --- a/ui/src/themes/nord.js +++ b/ui/src/themes/nord.js @@ -259,7 +259,6 @@ export default { }, details: { fontSize: '.875rem', - minWidth: '75vw', color: 'rgba(255,255,255, 0.8)', }, }, diff --git a/ui/src/themes/spotify.js b/ui/src/themes/spotify.js index 703d8159e..980183759 100644 --- a/ui/src/themes/spotify.js +++ b/ui/src/themes/spotify.js @@ -204,7 +204,6 @@ export default { }, details: { fontSize: '.875rem', - minWidth: '75vw', color: 'rgba(255,255,255, 0.8)', }, }, diff --git a/ui/src/themes/theme.test.js b/ui/src/themes/theme.test.js new file mode 100644 index 000000000..b65c3a5fe --- /dev/null +++ b/ui/src/themes/theme.test.js @@ -0,0 +1,14 @@ +import themes from './index' +import { describe, it, expect } from 'vitest' + +describe('NDPlaylistDetails styles', () => { + const themeEntries = Object.entries(themes) + + it.each(themeEntries)( + '%s should not set minWidth on details', + (themeName, theme) => { + const details = theme.overrides?.NDPlaylistDetails?.details + expect(details?.minWidth).toBeUndefined() + }, + ) +}) From e3f740cafb34b035928d94ef3c35b328815ac3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 8 Jun 2025 15:47:56 -0400 Subject: [PATCH 005/275] chore(deps): update TagLib to version 2.1 (#4185) * chore: update cross-taglib * fix(taglib): add logging for TagLib version Signed-off-by: Deluan --------- Signed-off-by: Deluan --- .github/workflows/pipeline.yml | 2 +- Dockerfile | 2 +- Makefile | 2 +- adapters/taglib/taglib.go | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 4ac1b2c6b..d2375a6e6 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: true env: - CROSS_TAGLIB_VERSION: "2.0.2-1" + CROSS_TAGLIB_VERSION: "2.1.0-1" IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }} jobs: diff --git a/Dockerfile b/Dockerfile index 4b4c3d18c..54913aca7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ COPY --from=xx-build /out/ /usr/bin/ ### Get TagLib FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS taglib-build ARG TARGETPLATFORM -ARG CROSS_TAGLIB_VERSION=2.0.2-1 +ARG CROSS_TAGLIB_VERSION=2.1.0-1 ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/ RUN < Date: Sun, 8 Jun 2025 18:45:06 -0400 Subject: [PATCH 006/275] test: verify agents fallback (#4191) --- core/agents/agents_test.go | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index ea12fb746..d72be4023 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -173,6 +173,42 @@ var _ = Describe("Agents", func() { Expect(err).To(MatchError(ErrNotFound)) Expect(mock.Args).To(BeEmpty()) }) + + Context("with multiple image agents", func() { + var first *testImageAgent + var second *testImageAgent + + BeforeEach(func() { + first = &testImageAgent{Name: "imgFail", Err: errors.New("fail")} + second = &testImageAgent{Name: "imgOk", Images: []ExternalImage{{URL: "ok", Size: 1}}} + Register("imgFail", func(model.DataStore) Interface { return first }) + Register("imgOk", func(model.DataStore) Interface { return second }) + }) + + It("falls back to the next agent on error", func() { + conf.Server.Agents = "imgFail,imgOk" + ag = createAgents(ds) + + images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid") + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}})) + Expect(first.Args).To(HaveExactElements("id", "artist", "mbid")) + Expect(second.Args).To(HaveExactElements("id", "artist", "mbid")) + }) + + It("falls back if the first agent returns no images", func() { + first.Err = nil + first.Images = []ExternalImage{} + conf.Server.Agents = "imgFail,imgOk" + ag = createAgents(ds) + + images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid") + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}})) + Expect(first.Args).To(HaveExactElements("id", "artist", "mbid")) + Expect(second.Args).To(HaveExactElements("id", "artist", "mbid")) + }) + }) }) Describe("GetSimilarArtists", func() { @@ -355,3 +391,17 @@ type emptyAgent struct { func (e *emptyAgent) AgentName() string { return "empty" } + +type testImageAgent struct { + Name string + Images []ExternalImage + Err error + Args []interface{} +} + +func (t *testImageAgent) AgentName() string { return t.Name } + +func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) { + t.Args = []interface{}{id, name, mbid} + return t.Images, t.Err +} From 7928adb3d128e5aed00dc272d425a0a87fcfac98 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 9 Jun 2025 14:30:48 -0400 Subject: [PATCH 007/275] build(docker): downgrade Alpine version from 3.21 to 3.19, oldest supported version. This is to reduce the image size, as we don't really need the latest. Signed-off-by: Deluan --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 54913aca7..2606d2153 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros ######################################################################################################################## ### Build xx (orignal image: tonistiigi/xx) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS xx-build +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build # v1.5.0 ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a @@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/ ######################################################################################################################## ### Get TagLib -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS taglib-build +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build ARG TARGETPLATFORM ARG CROSS_TAGLIB_VERSION=2.1.0-1 ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/ @@ -120,7 +120,7 @@ COPY --from=build /out / ######################################################################################################################## ### Build Final Image -FROM public.ecr.aws/docker/library/alpine:3.21 AS final +FROM public.ecr.aws/docker/library/alpine:3.19 AS final LABEL maintainer="deluan@navidrome.org" LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" From 5882889a80af6872adb2aec855d376e3de32dfdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 9 Jun 2025 17:06:10 -0400 Subject: [PATCH 008/275] feat(ui): Add Artist Radio and Shuffle options (#4186) * Add Play Similar option * Add pt-br translation for Play Similar * Refactor playSimilar and add helper * Improve Play Similar feedback * Add artist actions bar with shuffle and radio * Add Play Similar menu and align artist actions * Refine artist actions and revert menu option * fix(ui): enhance layout of ArtistActions and ArtistShow components Signed-off-by: Deluan * fix(i18n): revert unused changes Signed-off-by: Deluan * fix(ui): improve layout for mobile Signed-off-by: Deluan * fix(ui): improve error handling for fetching similar songs Signed-off-by: Deluan * fix(ui): enhance error logging for fetching songs in shuffle Signed-off-by: Deluan * refactor(ui): shuffle handling to use async/await for better readability Signed-off-by: Deluan * refactor(ui): simplify button label handling in ArtistActions component Signed-off-by: Deluan --------- Signed-off-by: Deluan --- resources/i18n/pt-br.json | 5 ++ ui/src/artist/ArtistActions.jsx | 122 +++++++++++++++++++++++++++ ui/src/artist/ArtistActions.test.jsx | 79 +++++++++++++++++ ui/src/artist/ArtistShow.jsx | 34 ++++++++ ui/src/i18n/en.json | 5 ++ ui/src/subsonic/index.js | 5 ++ ui/src/utils/index.js | 1 + ui/src/utils/playSimilar.js | 27 ++++++ 8 files changed, 278 insertions(+) create mode 100644 ui/src/artist/ArtistActions.jsx create mode 100644 ui/src/artist/ArtistActions.test.jsx create mode 100644 ui/src/utils/playSimilar.js diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index cfb3c8485..e105f1349 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -124,6 +124,10 @@ "remixer": "Remixador |||| Remixadores", "djmixer": "DJ Mixer |||| DJ Mixers", "performer": "Músico |||| Músicos" + }, + "actions": { + "shuffle": "Aleatório", + "radio": "Rádio" } }, "user": { @@ -407,6 +411,7 @@ "transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}", "transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão", "songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist", + "noSimilarSongsFound": "Nenhuma música semelhante encontrada", "noPlaylistsAvailable": "Nenhuma playlist", "delete_user_title": "Excluir usuário '%{name}'", "delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?", diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx new file mode 100644 index 000000000..33b9732eb --- /dev/null +++ b/ui/src/artist/ArtistActions.jsx @@ -0,0 +1,122 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useDispatch } from 'react-redux' +import { useMediaQuery } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { + Button, + TopToolbar, + sanitizeListRestProps, + useDataProvider, + useNotify, + useTranslate, +} from 'react-admin' +import ShuffleIcon from '@material-ui/icons/Shuffle' +import { IoIosRadio } from 'react-icons/io' +import { playTracks } from '../actions' +import { playSimilar } from '../utils' + +const useStyles = makeStyles((theme) => ({ + toolbar: { + minHeight: 'auto', + padding: '0 !important', + background: 'transparent', + boxShadow: 'none', + '& .MuiToolbar-root': { + minHeight: 'auto', + padding: '0 !important', + background: 'transparent', + }, + }, + button: { + [theme.breakpoints.down('xs')]: { + minWidth: 'auto', + padding: '8px 12px', + fontSize: '0.75rem', + '& .MuiButton-startIcon': { + marginRight: '4px', + }, + }, + }, + radioIcon: { + [theme.breakpoints.down('xs')]: { + fontSize: '1.5rem', + }, + }, +})) + +const ArtistActions = ({ className, record, ...rest }) => { + const dispatch = useDispatch() + const translate = useTranslate() + const dataProvider = useDataProvider() + const notify = useNotify() + const classes = useStyles() + const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs')) + + const handleShuffle = React.useCallback(async () => { + try { + const res = await dataProvider.getList('song', { + pagination: { page: 1, perPage: 500 }, + sort: { field: 'random', order: 'ASC' }, + filter: { album_artist_id: record.id, missing: false }, + }) + + const data = {} + const ids = [] + res.data.forEach((s) => { + data[s.id] = s + ids.push(s.id) + }) + dispatch(playTracks(data, ids)) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error fetching songs for shuffle:', e) + notify('ra.page.error', 'warning') + } + }, [dataProvider, dispatch, record, notify]) + + const handleRadio = React.useCallback(async () => { + try { + await playSimilar(dispatch, notify, record.id) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error starting radio for artist:', e) + notify('ra.page.error', 'warning') + } + }, [dispatch, notify, record]) + + return ( + + + + + ) +} + +ArtistActions.propTypes = { + className: PropTypes.string, + record: PropTypes.object.isRequired, +} + +ArtistActions.defaultProps = { + className: '', +} + +export default ArtistActions diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx new file mode 100644 index 000000000..2d9768971 --- /dev/null +++ b/ui/src/artist/ArtistActions.test.jsx @@ -0,0 +1,79 @@ +import React from 'react' +import { render, fireEvent, waitFor, screen } from '@testing-library/react' +import { TestContext } from 'ra-test' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import ArtistActions from './ArtistActions' +import subsonic from '../subsonic' +import { ThemeProvider, createMuiTheme } from '@material-ui/core/styles' + +const mockDispatch = vi.fn() +vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })) + +vi.mock('../subsonic', () => ({ + default: { getSimilarSongs2: vi.fn() }, +})) + +const mockNotify = vi.fn() +const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => mockNotify, + useDataProvider: () => ({ getList: mockGetList }), + useTranslate: () => (x) => x, + } +}) + +describe('ArtistActions', () => { + beforeEach(() => { + vi.clearAllMocks() + subsonic.getSimilarSongs2.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + similarSongs2: { song: [{ id: 'rec1' }] }, + }, + }, + }) + }) + + it('shuffles songs when Shuffle is clicked', async () => { + const theme = createMuiTheme() + render( + + + + + , + ) + + fireEvent.click(screen.getByText('resources.artist.actions.shuffle')) + await waitFor(() => + expect(mockGetList).toHaveBeenCalledWith('song', { + pagination: { page: 1, perPage: 500 }, + sort: { field: 'random', order: 'ASC' }, + filter: { album_artist_id: 'ar1', missing: false }, + }), + ) + expect(mockDispatch).toHaveBeenCalled() + }) + + it('starts radio when Radio is clicked', async () => { + const theme = createMuiTheme() + render( + + + + + , + ) + + fireEvent.click(screen.getByText('resources.artist.actions.radio')) + await waitFor(() => + expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100), + ) + expect(mockDispatch).toHaveBeenCalled() + }) +}) diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index e8e03f52e..c7b51780b 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -14,6 +14,34 @@ import AlbumGridView from '../album/AlbumGridView' import MobileArtistDetails from './MobileArtistDetails' import DesktopArtistDetails from './DesktopArtistDetails' import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js' +import ArtistActions from './ArtistActions' +import { makeStyles } from '@material-ui/core' + +const useStyles = makeStyles((theme) => ({ + actions: { + width: '100%', + justifyContent: 'flex-start', + display: 'flex', + paddingTop: '0.25em', + paddingBottom: '0.25em', + paddingLeft: '1em', + paddingRight: '1em', + flexWrap: 'wrap', + overflowX: 'auto', + [theme.breakpoints.down('xs')]: { + paddingLeft: '0.5em', + paddingRight: '0.5em', + gap: '0.5em', + justifyContent: 'space-around', + }, + }, + actionsContainer: { + paddingLeft: '.75rem', + [theme.breakpoints.down('xs')]: { + padding: '.5rem', + }, + }, +})) const ArtistDetails = (props) => { const record = useRecordContext(props) @@ -56,6 +84,7 @@ const ArtistShowLayout = (props) => { const record = useRecordContext() const { width } = props const [, perPageOptions] = useAlbumsPerPage(width) + const classes = useStyles() useResourceRefresh('artist', 'album') const maxPerPage = 90 @@ -79,6 +108,11 @@ const ArtistShowLayout = (props) => { <> {record && } />} {record && } + {record && ( +
+ +
+ )} {record && ( { return httpClient(url('getAlbumInfo', id)) } +const getSimilarSongs2 = (id, count = 100) => { + return httpClient(url('getSimilarSongs2', id, { count })) +} + const streamUrl = (id, options) => { return baseUrl( url('stream', id, { @@ -106,4 +110,5 @@ export default { streamUrl, getAlbumInfo, getArtistInfo, + getSimilarSongs2, } diff --git a/ui/src/utils/index.js b/ui/src/utils/index.js index 779b6f886..40470b01e 100644 --- a/ui/src/utils/index.js +++ b/ui/src/utils/index.js @@ -3,3 +3,4 @@ export * from './intersperse' export * from './notifications' export * from './openInNewTab' export * from './urls' +export * from './playSimilar' diff --git a/ui/src/utils/playSimilar.js b/ui/src/utils/playSimilar.js new file mode 100644 index 000000000..a4d7554fe --- /dev/null +++ b/ui/src/utils/playSimilar.js @@ -0,0 +1,27 @@ +import subsonic from '../subsonic' +import { playTracks } from '../actions' + +export const playSimilar = async (dispatch, notify, id) => { + const res = await subsonic.getSimilarSongs2(id, 100) + const data = res.json['subsonic-response'] + + if (data.status !== 'ok') { + throw new Error( + `Error fetching similar songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`, + ) + } + + const songs = data.similarSongs2?.song || [] + if (!songs.length) { + notify('message.noSimilarSongsFound', 'warning') + return + } + + const songData = {} + const ids = [] + songs.forEach((s) => { + songData[s.id] = s + ids.push(s.id) + }) + dispatch(playTracks(songData, ids)) +} From aee2a1f8bef1bbfa39685d24a3bc64b3ec74b4c4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 9 Jun 2025 17:56:59 -0400 Subject: [PATCH 009/275] fix(ui): artist buttons in spotify-ish Signed-off-by: Deluan --- ui/src/artist/ArtistShow.jsx | 49 ++++++++++++++++-------------- ui/src/themes/spotify.js | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 22 deletions(-) diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index c7b51780b..db8ed4566 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -17,31 +17,36 @@ import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js' import ArtistActions from './ArtistActions' import { makeStyles } from '@material-ui/core' -const useStyles = makeStyles((theme) => ({ - actions: { - width: '100%', - justifyContent: 'flex-start', - display: 'flex', - paddingTop: '0.25em', - paddingBottom: '0.25em', - paddingLeft: '1em', - paddingRight: '1em', - flexWrap: 'wrap', - overflowX: 'auto', - [theme.breakpoints.down('xs')]: { - paddingLeft: '0.5em', - paddingRight: '0.5em', - gap: '0.5em', - justifyContent: 'space-around', +const useStyles = makeStyles( + (theme) => ({ + actions: { + width: '100%', + justifyContent: 'flex-start', + display: 'flex', + paddingTop: '0.25em', + paddingBottom: '0.25em', + paddingLeft: '1em', + paddingRight: '1em', + flexWrap: 'wrap', + overflowX: 'auto', + [theme.breakpoints.down('xs')]: { + paddingLeft: '0.5em', + paddingRight: '0.5em', + gap: '0.5em', + justifyContent: 'space-around', + }, }, - }, - actionsContainer: { - paddingLeft: '.75rem', - [theme.breakpoints.down('xs')]: { - padding: '.5rem', + actionsContainer: { + paddingLeft: '.75rem', + [theme.breakpoints.down('xs')]: { + padding: '.5rem', + }, }, + }), + { + name: 'NDArtistShow', }, -})) +) const ArtistDetails = (props) => { const record = useRecordContext(props) diff --git a/ui/src/themes/spotify.js b/ui/src/themes/spotify.js index 980183759..c40ed20aa 100644 --- a/ui/src/themes/spotify.js +++ b/ui/src/themes/spotify.js @@ -242,6 +242,64 @@ export default { NDPlaylistShow: { playlistActions: musicListActions, }, + NDArtistShow: { + actions: { + padding: '2rem 0', + alignItems: 'center', + overflow: 'visible', + minHeight: '120px', + '@global': { + button: { + border: '1px solid transparent', + backgroundColor: 'inherit', + color: '#b3b3b3', + margin: '0 0.5rem', + '&:hover': { + border: '1px solid #b3b3b3', + backgroundColor: 'inherit !important', + }, + }, + // Hide shuffle button label (first button) + 'button:first-child>span:first-child>span': { + display: 'none', + }, + // Style shuffle button (first button) + 'button:first-child': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + background: spotifyGreen['500'], + color: '#fff', + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${spotifyGreen['500']} !important`, + border: 0, + }, + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: '#b3b3b3', + }, + }, + }, + actionsContainer: { + overflow: 'visible', + }, + }, NDAudioPlayer: { audioTitle: { color: '#fff', From a65140b9654f964ad876d6c12c9e9d0d25ff3e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 9 Jun 2025 19:07:42 -0400 Subject: [PATCH 010/275] feat(ui): add Play Artist's Top Songs button (#4204) * ui: add Play button to artist toolbar * refactor Signed-off-by: Deluan * test(ui): add tests for Play button functionality in ArtistActions Signed-off-by: Deluan * ui: update Play button label to Top Songs in ArtistActions Signed-off-by: Deluan --------- Signed-off-by: Deluan --- resources/i18n/pt-br.json | 2 + ui/src/artist/ArtistActions.jsx | 36 +++--- ui/src/artist/ArtistActions.test.jsx | 177 ++++++++++++++++++++++----- ui/src/artist/actions.js | 68 ++++++++++ ui/src/i18n/en.json | 2 + ui/src/subsonic/index.js | 5 + ui/src/utils/index.js | 1 - ui/src/utils/playSimilar.js | 27 ---- 8 files changed, 241 insertions(+), 77 deletions(-) create mode 100644 ui/src/artist/actions.js delete mode 100644 ui/src/utils/playSimilar.js diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index e105f1349..285a71523 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -126,6 +126,7 @@ "performer": "Músico |||| Músicos" }, "actions": { + "topSongs": "Mais tocadas", "shuffle": "Aleatório", "radio": "Rádio" } @@ -412,6 +413,7 @@ "transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão", "songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist", "noSimilarSongsFound": "Nenhuma música semelhante encontrada", + "noTopSongsFound": "Nenhuma música mais tocada encontrada", "noPlaylistsAvailable": "Nenhuma playlist", "delete_user_title": "Excluir usuário '%{name}'", "delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?", diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index 33b9732eb..c33ee892b 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -12,9 +12,9 @@ import { useTranslate, } from 'react-admin' import ShuffleIcon from '@material-ui/icons/Shuffle' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' import { IoIosRadio } from 'react-icons/io' -import { playTracks } from '../actions' -import { playSimilar } from '../utils' +import { playShuffle, playSimilar, playTopSongs } from './actions.js' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -53,21 +53,19 @@ const ArtistActions = ({ className, record, ...rest }) => { const classes = useStyles() const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const handlePlay = React.useCallback(async () => { + try { + await playTopSongs(dispatch, notify, record.name) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error fetching top songs for artist:', e) + notify('ra.page.error', 'warning') + } + }, [dispatch, notify, record]) + const handleShuffle = React.useCallback(async () => { try { - const res = await dataProvider.getList('song', { - pagination: { page: 1, perPage: 500 }, - sort: { field: 'random', order: 'ASC' }, - filter: { album_artist_id: record.id, missing: false }, - }) - - const data = {} - const ids = [] - res.data.forEach((s) => { - data[s.id] = s - ids.push(s.id) - }) - dispatch(playTracks(data, ids)) + await playShuffle(dataProvider, dispatch, record.id) } catch (e) { // eslint-disable-next-line no-console console.error('Error fetching songs for shuffle:', e) @@ -90,6 +88,14 @@ const ArtistActions = ({ className, record, ...rest }) => { className={`${className} ${classes.toolbar}`} {...sanitizeListRestProps(rest)} > +