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