diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index cfb3c848..e105f134 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 00000000..33b9732e
--- /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 00000000..2d976897
--- /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 e8e03f52..c7b51780 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 779b6f88..40470b01 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 00000000..a4d7554f
--- /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))
+}