import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react'; import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, '0')}`; } /** Strip dangerous tags/attributes from server-provided HTML */ function sanitizeHtml(html: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove()); doc.querySelectorAll('*').forEach(el => { Array.from(el.attributes).forEach(attr => { const name = attr.name.toLowerCase(); const val = attr.value.toLowerCase().trim(); if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) { el.removeAttribute(attr.name); } }); }); return doc.body.innerHTML; } export default function ArtistDetail() { const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [artist, setArtist] = useState(null); const [albums, setAlbums] = useState([]); const [featuredAlbums, setFeaturedAlbums] = useState([]); const [topSongs, setTopSongs] = useState([]); const [info, setInfo] = useState(null); const [loading, setLoading] = useState(true); const [radioLoading, setRadioLoading] = useState(false); const [isStarred, setIsStarred] = useState(false); const [openedLink, setOpenedLink] = useState(null); const [similarArtists, setSimilarArtists] = useState([]); const [similarLoading, setSimilarLoading] = useState(false); const playTrack = usePlayerStore(state => state.playTrack); const enqueue = usePlayerStore(state => state.enqueue); const clearQueue = usePlayerStore(state => state.clearQueue); const openContextMenu = usePlayerStore(state => state.openContextMenu); useEffect(() => { if (!id) return; setLoading(true); let ownAlbumIds: Set; getArtist(id).then(artistData => { setArtist(artistData.artist); setAlbums(artistData.albums); setIsStarred(!!artistData.artist.starred); ownAlbumIds = new Set(artistData.albums.map(a => a.id)); return Promise.all([ getArtistInfo(id).catch(() => null), getTopSongs(artistData.artist.name).catch(() => []), search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })), ]); }).then(([artistInfo, songsData, searchResults]) => { if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null); if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]); const featuredSongs = (searchResults.songs ?? []).filter( song => song.artistId === id && !ownAlbumIds.has(song.albumId) ); const albumMap = new Map(); featuredSongs.forEach(song => { if (!albumMap.has(song.albumId)) { albumMap.set(song.albumId, { id: song.albumId, name: song.album, artist: song.albumArtist ?? '', artistId: '', coverArt: song.coverArt, songCount: 1, duration: song.duration, year: song.year, }); } else { const a = albumMap.get(song.albumId)!; a.songCount++; a.duration += song.duration; } }); setFeaturedAlbums([...albumMap.values()]); setLoading(false); }).catch(err => { console.error(err); setLoading(false); }); }, [id]); useEffect(() => { if (!artist || !lastfmIsConfigured()) return; setSimilarArtists([]); setSimilarLoading(true); lastfmGetSimilarArtists(artist.name).then(async names => { if (names.length === 0) { setSimilarLoading(false); return; } const results = await Promise.all( names.slice(0, 30).map(name => search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] })) ) ); const seen = new Set([artist.id]); const found: SubsonicArtist[] = []; for (let i = 0; i < results.length; i++) { const targetName = names[i].toLowerCase(); const match = results[i].artists.find(a => a.name.toLowerCase() === targetName); if (match && !seen.has(match.id)) { seen.add(match.id); found.push(match); } } setSimilarArtists(found); setSimilarLoading(false); }).catch(() => setSimilarLoading(false)); }, [artist?.id]); const openLink = (url: string, key: string) => { open(url); setOpenedLink(key); setTimeout(() => setOpenedLink(null), 2500); }; const toggleStar = async () => { if (!artist) return; const currentlyStarred = isStarred; setIsStarred(!currentlyStarred); try { if (currentlyStarred) await unstar(artist.id, 'artist'); else await star(artist.id, 'artist'); } catch (e) { console.error('Failed to toggle star', e); setIsStarred(currentlyStarred); } }; const handlePlayAll = () => { if (topSongs.length > 0) { clearQueue(); playTrack(topSongs[0], topSongs); } }; const handleShuffle = () => { if (topSongs.length > 0) { const shuffled = [...topSongs].sort(() => Math.random() - 0.5); clearQueue(); playTrack(shuffled[0], shuffled); } }; const handleStartRadio = async () => { if (!artist) return; setRadioLoading(true); try { const similar = await getSimilarSongs2(artist.id, 50); if (similar.length > 0) { clearQueue(); playTrack(similar[0], similar); } else { alert(t('artistDetail.noRadio')); } } catch (e) { console.error('Radio start failed', e); } finally { setRadioLoading(false); } }; if (loading) { return (
); } if (!artist) { return (
{t('artistDetail.notFound')}
); } const coverId = artist.coverArt || artist.id; const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`; return (
{coverId ? ( { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> ) : ( )}

{artist.name}

{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
{(info?.lastFmUrl || artist.name) && (
{info?.lastFmUrl && ( )}
)}
{topSongs.length > 0 && ( <> )}
{/* Biography — sanitized HTML from server */} {info?.biography && (
)} {/* Top Songs */} {topSongs.length > 0 && ( <>

{t('artistDetail.topTracks')}

#
{t('artistDetail.trackTitle')}
{t('artistDetail.trackAlbum')}
{t('artistDetail.trackDuration')}
{topSongs.map((song, idx) => (
playTrack(song, topSongs)} onContextMenu={(e) => { e.preventDefault(); const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, }; openContextMenu(e.clientX, e.clientY, track, 'song'); }} >
{idx + 1}
{song.coverArt && ( { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> )}
{song.title}
{song.album}
{formatDuration(song.duration)}
))}
)} {/* Similar Artists (Last.fm) */} {lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && ( <>

{t('artistDetail.similarArtists')}

{similarLoading ? (
{t('artistDetail.loading')}
) : (
{similarArtists.map(a => ( ))}
)} )} {/* Albums */}

0 || lastfmIsConfigured()) ? '2rem' : '0', marginBottom: '1rem' }}> {t('artistDetail.albumsBy', { name: artist.name })}

{albums.length > 0 ? (
{albums.map(a => )}
) : (

{t('artistDetail.noAlbums')}

)} {/* Also Featured On */} {featuredAlbums.length > 0 && ( <>

{t('artistDetail.featuredOn')}

{featuredAlbums.map(a => )}
)}
); }