Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/app/components/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Info,
Pause,
Play,
Radio,
Shuffle,
} from 'lucide-react'
import { ButtonHTMLAttributes, ComponentPropsWithoutRef } from 'react'
Expand Down Expand Up @@ -137,6 +138,10 @@ function InfoIcon() {
return <Info className="w-5 h-5 drop-shadow-md" strokeWidth={2} />
}

function RadioIcon() {
return <Radio className="w-5 h-5 drop-shadow-md" strokeWidth={2} />
}

function EllipsisIcon() {
return <EllipsisVertical className="w-5 h-5 drop-shadow-md" strokeWidth={2} />
}
Expand All @@ -149,6 +154,7 @@ export const Actions = {
ShuffleIcon,
LikeIcon,
InfoIcon,
RadioIcon,
EllipsisIcon,
Dropdown,
}
9 changes: 9 additions & 0 deletions src/app/components/artist/buttons.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Actions } from '@/app/components/actions'
import { useArtistRadio } from '@/app/hooks/use-artist-radio'
import { useSongList } from '@/app/hooks/use-song-list'
import { subsonic } from '@/service/subsonic'
import { useAppPages, useAppStore } from '@/store/app.store'
Expand Down Expand Up @@ -32,6 +33,7 @@ export function ArtistButtons({
const isShuffleActive = usePlayerStore(
(state) => state.playerState.isShuffleActive,
)
const { isRadioAvailable, startRadio } = useArtistRadio(artist)
const hideFavoritesSection = useAppStore().pages.hideFavoritesSection
const isArtistStarred = artist.starred !== undefined

Expand Down Expand Up @@ -88,6 +90,7 @@ export function ArtistButtons({
: t('playlist.buttons.play', { name: artist.name }),
shuffle: t('playlist.buttons.shuffle', { name: artist.name }),
options: t('playlist.buttons.options', { name: artist.name }),
radio: t('artist.buttons.radio', { artist: artist.name }),
like: isArtistStarred
? t('album.buttons.dislike', { name: artist.name })
: t('album.buttons.like', { name: artist.name }),
Expand Down Expand Up @@ -116,6 +119,12 @@ export function ArtistButtons({
<Actions.ShuffleIcon />
</Actions.Button>

{isRadioAvailable && (
<Actions.Button tooltip={buttonsTooltips.radio} onClick={startRadio}>
<Actions.RadioIcon />
</Actions.Button>
)}

{!hideFavoritesSection && (
<>
<Actions.Button
Expand Down
78 changes: 78 additions & 0 deletions src/app/hooks/use-artist-radio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useTranslation } from 'react-i18next'
import { toast } from 'react-toastify'
import { useSongList } from '@/app/hooks/use-song-list'
import { subsonic } from '@/service/subsonic'
import { usePlayerActions } from '@/store/player.store'
import { IArtist } from '@/types/responses/artist'
import { ISong } from '@/types/responses/song'
import { checkServerType, getServerExtensions } from '@/utils/servers'

export function useArtistRadio(artist: IArtist) {
const { t } = useTranslation()
const { getArtistAllSongs } = useSongList()
const { setSongList } = usePlayerActions()
const { sonicSimilarityEnabled } = getServerExtensions()
const { isNavidrome } = checkServerType()

// Sonic radio needs the sonicSimilarity extension (e.g. AudioMuse-AI); the
// classic id3 artist radio (getSimilarSongs2) works on any Navidrome server.
const isRadioAvailable = Boolean(sonicSimilarityEnabled || isNavidrome)

function dedupeById(list: ISong[]) {
return list.filter(
(song, index, arr) => arr.findIndex((s) => s.id === song.id) === index,
)
}

async function getSonicRadio(): Promise<ISong[]> {
// getSonicSimilarTracks is seeded by a song, so use the artist's top song
// (falling back to their first available track).
let seed: ISong | undefined
try {
const topSongs = await subsonic.songs.getTopSongs(artist.name)
seed = topSongs?.[0]
} catch {
seed = undefined
}
if (!seed) {
const artistSongs = await getArtistAllSongs(artist.name)
seed = artistSongs?.[0]
}
if (!seed) return []

const similarTracks = await subsonic.songs.getSonicSimilarTracks(seed.id)
if (similarTracks.length === 0) return []

// The endpoint may return the seed itself (similarity 1.0); de-duplicate.
return dedupeById([seed, ...similarTracks])
}

async function startRadio() {
let radioList: ISong[] = []

if (sonicSimilarityEnabled) {
radioList = await getSonicRadio()
}

// Fall back to Navidrome's id3-based similar songs (the classic radio).
if (radioList.length === 0) {
radioList = await subsonic.songs.getSimilarSongs2(artist.id)
}

if (radioList.length === 0) {
toast.error(t('artist.radio.empty'))
return
}

setSongList(radioList, 0, false, {
id: artist.id,
name: artist.name,
type: 'artist',
})
}

return {
isRadioAvailable,
startRadio,
}
}
4 changes: 4 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,12 @@
"buttons": {
"play": "Play {{artist}} radio",
"shuffle": "Play {{artist}} radio in shuffle mode",
"radio": "Start radio based on {{artist}}",
"options": "More options for {{artist}}"
},
"radio": {
"empty": "No similar tracks found to start a radio."
},
"info": {
"albumsCount_one": "{{count}} album",
"albumsCount_other": "{{count}} albums"
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,11 @@
"buttons": {
"options": "Más opciones para {{artist}}",
"shuffle": "Reproducir radio de {{artist}} en modo aleatorio",
"play": "Reproducir radio de {{artist}}"
"play": "Reproducir radio de {{artist}}",
"radio": "Iniciar radio basada en {{artist}}"
},
"radio": {
"empty": "No se encontraron canciones similares para iniciar una radio."
},
"info": {
"albumsCount_one": "{{count}} álbum",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,12 @@
"buttons": {
"play": "Tocar a rádio de {{artist}}",
"shuffle": "Tocar a rádio de {{artist}} no modo aleatório",
"radio": "Iniciar rádio baseada em {{artist}}",
"options": "Mais opções para {{artist}}"
},
"radio": {
"empty": "Nenhuma faixa semelhante encontrada para iniciar uma rádio."
},
"info": {
"albumsCount_one": "{{count}} álbum",
"albumsCount_many": "{{count}} álbuns",
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@
"buttons": {
"shuffle": "Tocar a rádio de {{artist}} no modo aleatório",
"options": "Mais opções para {{artist}}",
"play": "Tocar a rádio de {{artist}}"
"play": "Tocar a rádio de {{artist}}",
"radio": "Iniciar rádio baseada em {{artist}}"
},
"radio": {
"empty": "Não foram encontradas faixas semelhantes para iniciar uma rádio."
},
"info": {
"albumsCount_one": "{{count}} álbum",
Expand Down
33 changes: 33 additions & 0 deletions src/service/songs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
FavoritesResponse,
GetSongResponse,
RandomSongsResponse,
SimilarSongsResponse,
SonicSimilarTracksResponse,
TopSongsResponse,
} from '@/types/responses/song'
import { search } from './search'
Expand Down Expand Up @@ -52,6 +54,35 @@ async function getTopSongs(artistName: string) {
return response?.data.topSongs.song
}

async function getSonicSimilarTracks(id: string, count = 100) {
const response = await httpClient<SonicSimilarTracksResponse>(
'/getSonicSimilarTracks',
{
method: 'GET',
query: {
id,
count,
},
},
)

const matches = response?.data.sonicMatch ?? []

return matches.map((match) => match.entry)
}

async function getSimilarSongs2(id: string, count = 100) {
const response = await httpClient<SimilarSongsResponse>('/getSimilarSongs2', {
method: 'GET',
query: {
id,
count,
},
})

return response?.data.similarSongs2?.song ?? []
}

async function getAllSongs(songCount: number) {
const response = await search.get({
query: '',
Expand Down Expand Up @@ -79,6 +110,8 @@ export const songs = {
getAllSongs,
getFavoriteSongs,
getRandomSongs,
getSimilarSongs2,
getSonicSimilarTracks,
getTopSongs,
getSong,
}
11 changes: 11 additions & 0 deletions src/types/responses/song.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ export interface RandomSongsResponse
export interface TopSongsResponse
extends SubsonicResponse<{ topSongs: SongList }> {}

export interface ISonicMatch {
entry: ISong
similarity: number
}

export interface SonicSimilarTracksResponse
extends SubsonicResponse<{ sonicMatch?: ISonicMatch[] }> {}

export interface SimilarSongsResponse
extends SubsonicResponse<{ similarSongs2?: { song?: ISong[] } }> {}

export interface FavoritesResponse
extends SubsonicResponse<{ starred2: SongList }> {}

Expand Down
6 changes: 6 additions & 0 deletions src/utils/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ export function getServerExtensions() {
extensionsSupported.songLyrics &&
extensionsSupported.songLyrics.length > 0

const sonicSimilarityEnabled =
extensionsSupported &&
extensionsSupported.sonicSimilarity &&
extensionsSupported.sonicSimilarity.length > 0

return {
songLyricsEnabled,
sonicSimilarityEnabled,
}
}