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:
Deluan Quintão
2026-01-25 16:16:43 -05:00
committed by GitHub
parent b455546fdf
commit 772d1f359b
29 changed files with 2082 additions and 525 deletions
+2 -1
View File
@@ -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 -46
View File
@@ -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 },
+19
View File
@@ -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'),
+121 -1
View File
@@ -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()
})
})
})
+76
View File
@@ -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
View File
@@ -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}'",