feat(ui): add Play Artist's Top Songs button (#4204)

* ui: add Play button to artist toolbar

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* test(ui): add tests for Play button functionality in ArtistActions

Signed-off-by: Deluan <deluan@navidrome.org>

* ui: update Play button label to Top Songs in ArtistActions

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-06-09 19:07:42 -04:00
committed by Deluan
parent aee2a1f8be
commit a65140b965
8 changed files with 241 additions and 77 deletions
+21 -15
View File
@@ -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)}
>
<Button
onClick={handlePlay}
label={translate('resources.artist.actions.topSongs')}
className={classes.button}
size={isMobile ? 'small' : 'medium'}
>
<PlayArrowIcon />
</Button>
<Button
onClick={handleShuffle}
label={translate('resources.artist.actions.shuffle')}
+143 -34
View File
@@ -4,13 +4,13 @@ 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'
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
const mockDispatch = vi.fn()
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch }))
vi.mock('../subsonic', () => ({
default: { getSimilarSongs2: vi.fn() },
default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() },
}))
const mockNotify = vi.fn()
@@ -27,8 +27,28 @@ vi.mock('react-admin', async (importOriginal) => {
})
describe('ArtistActions', () => {
const defaultRecord = { id: 'ar1', name: 'Artist' }
const renderArtistActions = (record = defaultRecord) => {
const theme = createTheme()
return render(
<TestContext>
<ThemeProvider theme={theme}>
<ArtistActions record={record} />
</ThemeProvider>
</TestContext>,
)
}
const clickActionButton = (actionKey) => {
fireEvent.click(screen.getByText(`resources.artist.actions.${actionKey}`))
}
beforeEach(() => {
vi.clearAllMocks()
// Mock console.error to suppress error logging in tests
vi.spyOn(console, 'error').mockImplementation(() => {})
subsonic.getSimilarSongs2.mockResolvedValue({
json: {
'subsonic-response': {
@@ -37,43 +57,132 @@ describe('ArtistActions', () => {
},
},
})
subsonic.getTopSongs.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
topSongs: { song: [{ id: 'rec1' }] },
},
},
})
})
it('shuffles songs when Shuffle is clicked', async () => {
const theme = createMuiTheme()
render(
<TestContext>
<ThemeProvider theme={theme}>
<ArtistActions record={{ id: 'ar1' }} />
</ThemeProvider>
</TestContext>,
)
describe('Shuffle action', () => {
it('shuffles songs when clicked', async () => {
renderArtistActions()
clickActionButton('shuffle')
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()
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(
<TestContext>
<ThemeProvider theme={theme}>
<ArtistActions record={{ id: 'ar1' }} />
</ThemeProvider>
</TestContext>,
)
describe('Radio action', () => {
it('starts radio when clicked', async () => {
renderArtistActions()
clickActionButton('radio')
fireEvent.click(screen.getByText('resources.artist.actions.radio'))
await waitFor(() =>
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100),
)
expect(mockDispatch).toHaveBeenCalled()
await waitFor(() =>
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100),
)
expect(mockDispatch).toHaveBeenCalled()
})
})
describe('Play action', () => {
it('plays top songs when clicked', async () => {
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
expect(mockDispatch).toHaveBeenCalled()
})
it('handles API rejection', async () => {
subsonic.getTopSongs.mockRejectedValue(new Error('Network error'))
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
expect(mockDispatch).not.toHaveBeenCalled()
})
it('handles failed API response', async () => {
subsonic.getTopSongs.mockResolvedValue({
json: {
'subsonic-response': {
status: 'failed',
error: { code: 40, message: 'Wrong username or password' },
},
},
})
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning')
expect(mockDispatch).not.toHaveBeenCalled()
})
it('handles empty song list', async () => {
subsonic.getTopSongs.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
topSongs: { song: [] },
},
},
})
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
expect(mockNotify).toHaveBeenCalledWith(
'message.noTopSongsFound',
'warning',
)
expect(mockDispatch).not.toHaveBeenCalled()
})
it('handles missing topSongs property', async () => {
subsonic.getTopSongs.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
// topSongs property is missing
},
},
})
renderArtistActions()
clickActionButton('topSongs')
await waitFor(() =>
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
)
expect(mockNotify).toHaveBeenCalledWith(
'message.noTopSongsFound',
'warning',
)
expect(mockDispatch).not.toHaveBeenCalled()
})
})
})
+68
View File
@@ -0,0 +1,68 @@
import subsonic from '../subsonic/index.js'
import { playTracks } from '../actions/index.js'
export const playTopSongs = async (dispatch, notify, artistName) => {
const res = await subsonic.getTopSongs(artistName, 100)
const data = res.json['subsonic-response']
if (data.status !== 'ok') {
throw new Error(
`Error fetching top songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`,
)
}
const songs = data.topSongs?.song || []
if (!songs.length) {
notify('message.noTopSongsFound', 'warning')
return
}
const songData = {}
const ids = []
songs.forEach((s) => {
songData[s.id] = s
ids.push(s.id)
})
dispatch(playTracks(songData, ids))
}
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))
}
export const playShuffle = async (dataProvider, dispatch, id) => {
const res = await dataProvider.getList('song', {
pagination: { page: 1, perPage: 500 },
sort: { field: 'random', order: 'ASC' },
filter: { album_artist_id: id, missing: false },
})
const data = {}
const ids = []
res.data.forEach((s) => {
data[s.id] = s
ids.push(s.id)
})
dispatch(playTracks(data, ids))
}
+2
View File
@@ -127,6 +127,7 @@
"performer": "Performer |||| Performers"
},
"actions": {
"topSongs": "Top Songs",
"shuffle": "Shuffle",
"radio": "Radio"
}
@@ -415,6 +416,7 @@
"transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.",
"songsAddedToPlaylist": "Added 1 song to playlist |||| Added %{smart_count} songs to playlist",
"noSimilarSongsFound": "No similar songs found",
"noTopSongsFound": "No top songs found",
"noPlaylistsAvailable": "None available",
"delete_user_title": "Delete user '%{name}'",
"delete_user_content": "Are you sure you want to delete this user and all their data (including playlists and preferences)?",
+5
View File
@@ -86,6 +86,10 @@ const getSimilarSongs2 = (id, count = 100) => {
return httpClient(url('getSimilarSongs2', id, { count }))
}
const getTopSongs = (artist, count = 50) => {
return httpClient(url('getTopSongs', null, { artist, count }))
}
const streamUrl = (id, options) => {
return baseUrl(
url('stream', id, {
@@ -110,5 +114,6 @@ export default {
streamUrl,
getAlbumInfo,
getArtistInfo,
getTopSongs,
getSimilarSongs2,
}
-1
View File
@@ -3,4 +3,3 @@ export * from './intersperse'
export * from './notifications'
export * from './openInNewTab'
export * from './urls'
export * from './playSimilar'
-27
View File
@@ -1,27 +0,0 @@
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))
}