feat: add similar songs functionality in agents, and Instant Mix (song-based) to UI (#4919)
* refactor: rename ArtistRadio to SimilarSongs for clarity and consistency Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement GetSimilarSongsByTrack and related functionality for song similarity retrieval Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance GetSimilarSongsByTrack to include artist and album details and update tests Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance song matching by implementing title and artist filtering in loadTracksByTitleAndArtist Signed-off-by: Deluan <deluan@navidrome.org> * test: add unit tests for song matching functionality in provider Signed-off-by: Deluan <deluan@navidrome.org> * refactor: extract song matching functionality into its own file Signed-off-by: Deluan <deluan@navidrome.org> * docs: clarify similarSongsFallback function description in provider.go Signed-off-by: Deluan <deluan@navidrome.org> * refactor: initialize result slice for songs with capacity based on response length Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify agent method calls for retrieving images and similar songs Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify agent method calls for retrieving images and similar songs Signed-off-by: Deluan <deluan@navidrome.org> * refactor: remove outdated comments in GetSimilarSongs methods Signed-off-by: Deluan <deluan@navidrome.org> * fix: use composite key for song matches to handle duplicates by title and artist Signed-off-by: Deluan <deluan@navidrome.org> * refactor: consolidate expectations setup for similar songs tests Signed-off-by: Deluan <deluan@navidrome.org> * feat: add instant mix action to song context menu and update translations Signed-off-by: Deluan <deluan@navidrome.org> * fix(provider): handle unknown entity types in GetSimilarSongs Signed-off-by: Deluan <deluan@navidrome.org> * refactor: move playSimilar action to playbackActions and streamline song processing Signed-off-by: Deluan <deluan@navidrome.org> * format Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance instant mix functionality with loading notification and shuffle option Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement fuzzy matching for similar songs based on configurable threshold Signed-off-by: Deluan <deluan@navidrome.org> * refactor: implement track matching with multiple specificity levels Signed-off-by: Deluan <deluan@navidrome.org> * refactor: enhance track matching by implementing unified scoring with specificity levels Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance deezer top tracks result with album Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance track matching with fuzzy album similarity for improved scoring Signed-off-by: Deluan <deluan@navidrome.org> * docs: document multi-phase song matching algorithm with detailed scoring and prioritization Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -14,7 +14,8 @@ import {
|
||||
import ShuffleIcon from '@material-ui/icons/Shuffle'
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||
import { IoIosRadio } from 'react-icons/io'
|
||||
import { playShuffle, playSimilar, playTopSongs } from './actions.js'
|
||||
import { playShuffle, playTopSongs } from './actions.js'
|
||||
import { playSimilar } from '../common/playbackActions.js'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
toolbar: {
|
||||
|
||||
@@ -1,31 +1,6 @@
|
||||
import subsonic from '../subsonic/index.js'
|
||||
import { playTracks } from '../actions/index.js'
|
||||
|
||||
const mapReplayGain = (song) => {
|
||||
const { replayGain: rg } = song
|
||||
if (!rg) {
|
||||
return song
|
||||
}
|
||||
|
||||
return {
|
||||
...song,
|
||||
...(rg.albumGain !== undefined && { rgAlbumGain: rg.albumGain }),
|
||||
...(rg.albumPeak !== undefined && { rgAlbumPeak: rg.albumPeak }),
|
||||
...(rg.trackGain !== undefined && { rgTrackGain: rg.trackGain }),
|
||||
...(rg.trackPeak !== undefined && { rgTrackPeak: rg.trackPeak }),
|
||||
}
|
||||
}
|
||||
|
||||
const processSongsForPlayback = (songs) => {
|
||||
const songData = {}
|
||||
const ids = []
|
||||
songs.forEach((s) => {
|
||||
const song = mapReplayGain(s)
|
||||
songData[song.id] = song
|
||||
ids.push(song.id)
|
||||
})
|
||||
return { songData, ids }
|
||||
}
|
||||
import { processSongsForPlayback } from '../common/playbackActions.js'
|
||||
|
||||
export const playTopSongs = async (dispatch, notify, artistName) => {
|
||||
const res = await subsonic.getTopSongs(artistName, 100)
|
||||
@@ -47,26 +22,6 @@ export const playTopSongs = async (dispatch, notify, artistName) => {
|
||||
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, ids } = processSongsForPlayback(songs)
|
||||
dispatch(playTracks(songData, ids))
|
||||
}
|
||||
|
||||
export const playShuffle = async (dataProvider, dispatch, id) => {
|
||||
const res = await dataProvider.getList('song', {
|
||||
pagination: { page: 1, perPage: 500 },
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../actions'
|
||||
import { LoveButton } from './LoveButton'
|
||||
import config from '../config'
|
||||
import { playSimilar } from './playbackActions.js'
|
||||
import { formatBytes } from '../utils'
|
||||
import { useRedirect } from 'react-admin'
|
||||
|
||||
@@ -86,6 +87,24 @@ export const SongContextMenu = ({
|
||||
label: translate('resources.song.actions.addToQueue'),
|
||||
action: (record) => dispatch(addTracks({ [record.id]: record })),
|
||||
},
|
||||
instantMix: {
|
||||
enabled: config.enableExternalServices,
|
||||
label: translate('resources.song.actions.instantMix'),
|
||||
action: async (record) => {
|
||||
notify('message.startingInstantMix', { type: 'info' })
|
||||
try {
|
||||
const id = record.mediaFileId || record.id
|
||||
await playSimilar(dispatch, notify, id, {
|
||||
seedRecord: record,
|
||||
shuffle: true,
|
||||
})
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error starting instant mix:', e)
|
||||
notify('ra.page.error', { type: 'warning' })
|
||||
}
|
||||
},
|
||||
},
|
||||
addToPlaylist: {
|
||||
enabled: true,
|
||||
label: translate('resources.song.actions.addToPlaylist'),
|
||||
|
||||
@@ -3,19 +3,36 @@ import { render, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { TestContext } from 'ra-test'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { SongContextMenu } from './SongContextMenu'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
vi.mock('../dataProvider', () => ({
|
||||
httpClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() }))
|
||||
vi.mock('../subsonic', () => ({
|
||||
default: { getSimilarSongs2: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../config', () => ({
|
||||
default: {
|
||||
enableDownloads: true,
|
||||
enableFavourites: true,
|
||||
enableSharing: true,
|
||||
enableExternalServices: true,
|
||||
},
|
||||
}))
|
||||
|
||||
const mockDispatch = vi.fn()
|
||||
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch }))
|
||||
|
||||
const getPlaylistsMock = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('react-admin', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
useNotify: () => mockNotify,
|
||||
useRedirect: () => (url) => {
|
||||
window.location.hash = `#${url}`
|
||||
},
|
||||
@@ -35,6 +52,14 @@ describe('SongContextMenu', () => {
|
||||
getPlaylistsMock.mockResolvedValue({
|
||||
data: [{ id: 'pl1', name: 'Pl 1' }],
|
||||
})
|
||||
subsonic.getSimilarSongs2.mockResolvedValue({
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
status: 'ok',
|
||||
similarSongs2: { song: [{ id: 's1' }] },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('navigates to playlist when selected', async () => {
|
||||
@@ -104,4 +129,99 @@ describe('SongContextMenu', () => {
|
||||
)
|
||||
expect(mockOnClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('Instant Mix action', () => {
|
||||
it('calls getSimilarSongs2 with song id and shows loading notification', async () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
await waitFor(() =>
|
||||
screen.getByText(/resources\.song\.actions\.instantMix/),
|
||||
)
|
||||
fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/))
|
||||
|
||||
// Verify loading notification is shown
|
||||
expect(mockNotify).toHaveBeenCalledWith('message.startingInstantMix', {
|
||||
type: 'info',
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('song1', 100),
|
||||
)
|
||||
expect(mockDispatch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('plays seed song first followed by similar songs', async () => {
|
||||
const seedRecord = { id: 'song1', title: 'Seed Song', size: 1 }
|
||||
render(
|
||||
<TestContext>
|
||||
<SongContextMenu record={seedRecord} resource="song" />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
await waitFor(() =>
|
||||
screen.getByText(/resources\.song\.actions\.instantMix/),
|
||||
)
|
||||
fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/))
|
||||
|
||||
await waitFor(() => expect(mockDispatch).toHaveBeenCalled())
|
||||
|
||||
// Verify dispatch was called with playTracks action
|
||||
const dispatchCall = mockDispatch.mock.calls.find(
|
||||
(call) => call[0]?.type === 'PLAYER_PLAY_TRACKS',
|
||||
)
|
||||
expect(dispatchCall).toBeDefined()
|
||||
|
||||
// Verify seed song is first (id property contains the first song to play)
|
||||
const { id, data } = dispatchCall[0]
|
||||
expect(id).toBe('song1')
|
||||
// Verify seed song data is included
|
||||
expect(data['song1']).toBeDefined()
|
||||
})
|
||||
|
||||
it('uses mediaFileId when available (playlist context)', async () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SongContextMenu
|
||||
record={{
|
||||
id: 'playlistTrackId',
|
||||
mediaFileId: 'actualSongId',
|
||||
size: 1,
|
||||
}}
|
||||
resource="song"
|
||||
/>
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
await waitFor(() =>
|
||||
screen.getByText(/resources\.song\.actions\.instantMix/),
|
||||
)
|
||||
fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith(
|
||||
'actualSongId',
|
||||
100,
|
||||
),
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockDispatch).toHaveBeenCalled())
|
||||
|
||||
// Verify the mediaFileId is used as the seed song id
|
||||
const dispatchCall = mockDispatch.mock.calls.find(
|
||||
(call) => call[0]?.type === 'PLAYER_PLAY_TRACKS',
|
||||
)
|
||||
expect(dispatchCall).toBeDefined()
|
||||
const { id, data } = dispatchCall[0]
|
||||
expect(id).toBe('actualSongId')
|
||||
// Verify seed song data is included
|
||||
expect(data['actualSongId']).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import subsonic from '../subsonic/index.js'
|
||||
import { playTracks } from '../actions/index.js'
|
||||
|
||||
const shuffleArray = (array) => {
|
||||
const shuffled = [...array]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
return shuffled
|
||||
}
|
||||
|
||||
const mapReplayGain = (song) => {
|
||||
const { replayGain: rg } = song
|
||||
if (!rg) {
|
||||
return song
|
||||
}
|
||||
|
||||
return {
|
||||
...song,
|
||||
...(rg.albumGain !== undefined && { rgAlbumGain: rg.albumGain }),
|
||||
...(rg.albumPeak !== undefined && { rgAlbumPeak: rg.albumPeak }),
|
||||
...(rg.trackGain !== undefined && { rgTrackGain: rg.trackGain }),
|
||||
...(rg.trackPeak !== undefined && { rgTrackPeak: rg.trackPeak }),
|
||||
}
|
||||
}
|
||||
|
||||
export const processSongsForPlayback = (songs) => {
|
||||
const songData = {}
|
||||
const ids = []
|
||||
songs.forEach((s) => {
|
||||
const song = mapReplayGain(s)
|
||||
songData[song.id] = song
|
||||
ids.push(song.id)
|
||||
})
|
||||
return { songData, ids }
|
||||
}
|
||||
|
||||
export const playSimilar = async (dispatch, notify, id, options = {}) => {
|
||||
const { seedRecord = null, shuffle = false } = options
|
||||
|
||||
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'})`,
|
||||
)
|
||||
}
|
||||
|
||||
let songs = data.similarSongs2?.song || []
|
||||
|
||||
// Randomize similar songs if requested
|
||||
if (shuffle) {
|
||||
songs = shuffleArray(songs)
|
||||
}
|
||||
|
||||
// If no similar songs found and no seed, show warning
|
||||
if (!songs.length && !seedRecord) {
|
||||
notify('message.noSimilarSongsFound', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const { songData, ids } = processSongsForPlayback(songs)
|
||||
|
||||
// Prepend seed song if provided
|
||||
if (seedRecord) {
|
||||
const seedId = seedRecord.mediaFileId || seedRecord.id
|
||||
// Remove seed from similar songs if it appears there
|
||||
const filteredIds = ids.filter((songId) => songId !== seedId)
|
||||
songData[seedId] = mapReplayGain(seedRecord)
|
||||
dispatch(playTracks(songData, [seedId, ...filteredIds]))
|
||||
} else {
|
||||
dispatch(playTracks(songData, ids))
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -47,7 +47,8 @@
|
||||
"shuffleAll": "Shuffle All",
|
||||
"download": "Download",
|
||||
"playNext": "Play Next",
|
||||
"info": "Get Info"
|
||||
"info": "Get Info",
|
||||
"instantMix": "Instant Mix"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -558,6 +559,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",
|
||||
"startingInstantMix": "Loading Instant Mix...",
|
||||
"noTopSongsFound": "No top songs found",
|
||||
"noPlaylistsAvailable": "None available",
|
||||
"delete_user_title": "Delete user '%{name}'",
|
||||
|
||||
Reference in New Issue
Block a user