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:
@@ -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')}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user