import { useEffect, useState, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; import CoverLightbox from '../components/CoverLightbox'; import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2 } from 'lucide-react'; import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; import { invalidateCoverArt } from '../utils/imageCache'; import { showToast } from '../utils/toast'; import StarRating from '../components/StarRating'; 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 [playAllLoading, setPlayAllLoading] = useState(false); const [isStarred, setIsStarred] = useState(false); const [openedLink, setOpenedLink] = useState(null); const [similarArtists, setSimilarArtists] = useState([]); const [similarLoading, setSimilarLoading] = useState(false); const [artistInfoLoading, setArtistInfoLoading] = useState(false); const [featuredLoading, setFeaturedLoading] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false); const [bioExpanded, setBioExpanded] = useState(false); const [uploading, setUploading] = useState(false); const [coverRevision, setCoverRevision] = useState(0); const imageInputRef = useRef(null); const playTrack = usePlayerStore(state => state.playTrack); const enqueue = usePlayerStore(state => state.enqueue); const clearQueue = usePlayerStore(state => state.clearQueue); const openContextMenu = usePlayerStore(state => state.openContextMenu); const currentTrack = usePlayerStore(state => state.currentTrack); const isPlaying = usePlayerStore(state => state.isPlaying); const downloadArtist = useOfflineStore(s => s.downloadArtist); const bulkProgress = useOfflineJobStore(s => s.bulkProgress); const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; const audiomuseNavidromeEnabled = useAuthStore( s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]), ); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer); const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown'; const [artistEntityRating, setArtistEntityRating] = useState(0); useEffect(() => { if (!id) return; let cancelled = false; setLoading(true); setInfo(null); setTopSongs([]); setFeaturedAlbums([]); getArtist(id).then(artistData => { if (cancelled) return; setArtist(artistData.artist); setAlbums(artistData.albums); setIsStarred(!!artistData.artist.starred); // Render the page immediately from local data setLoading(false); getTopSongs(artistData.artist.name).then(songsData => { if (!cancelled) setTopSongs(songsData ?? []); }).catch(() => {}); }).catch(err => { if (!cancelled) { console.error(err); setLoading(false); } }); return () => { cancelled = true; }; }, [id]); useEffect(() => { if (!id) return; let cancelled = false; setArtistInfoLoading(true); getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined }) .then(artistInfo => { if (!cancelled) setInfo(artistInfo ?? null); }) .catch(() => { if (!cancelled) setInfo(null); }) .finally(() => { if (!cancelled) setArtistInfoLoading(false); }); return () => { cancelled = true; }; }, [id, audiomuseNavidromeEnabled]); useEffect(() => { if (!id) return; if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0); }, [id, artist?.id, artist?.userRating]); const handleArtistEntityRating = async (rating: number) => { if (!artist || artist.id !== id) return; const artistId = artist.id; const ratingAtStart = artist.userRating ?? 0; setArtistEntityRating(rating); if (artistEntityRatingSupport !== 'full') return; try { await setRating(artistId, rating); setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a)); } catch (err) { setArtistEntityRating(ratingAtStart); setEntityRatingSupport(activeServerId, 'track_only'); showToast( typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), 4500, 'error', ); } }; // "Also Featured On" — loaded in background after main content renders useEffect(() => { if (!id || !artist) return; const ownAlbumIds = new Set(albums.map(a => a.id)); setFeaturedLoading(true); search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }) .catch(() => ({ songs: [], albums: [], artists: [] })) .then(searchResults => { 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()]); setFeaturedLoading(false); }); }, [artist?.id, musicLibraryFilterVersion]); useEffect(() => { if (!artist || audiomuseNavidromeEnabled || !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, musicLibraryFilterVersion, audiomuseNavidromeEnabled]); /** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */ useEffect(() => { if (!artist || !audiomuseNavidromeEnabled || !lastfmIsConfigured()) return; if (artistInfoLoading) return; if ((info?.similarArtist?.length ?? 0) > 0) 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, artist?.name, musicLibraryFilterVersion, audiomuseNavidromeEnabled, artistInfoLoading, info?.similarArtist?.length, ]); useEffect(() => { if (!audiomuseNavidromeEnabled) return; if ((info?.similarArtist?.length ?? 0) > 0) { setSimilarArtists([]); setSimilarLoading(false); } }, [id, audiomuseNavidromeEnabled, info?.similarArtist?.length]); 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 fetchAllTracks = async () => { const results = await Promise.all(albums.map(a => getAlbum(a.id))); const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0)); return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack); }; const handlePlayAll = async () => { if (albums.length === 0) return; setPlayAllLoading(true); try { const tracks = await fetchAllTracks(); if (tracks.length > 0) playTrack(tracks[0], tracks); } finally { setPlayAllLoading(false); } }; const handleShuffle = async () => { if (albums.length === 0) return; setPlayAllLoading(true); try { const tracks = await fetchAllTracks(); if (tracks.length > 0) { const shuffled = [...tracks].sort(() => Math.random() - 0.5); playTrack(shuffled[0], shuffled); } } finally { setPlayAllLoading(false); } }; const handleStartRadio = async () => { if (!artist) return; setRadioLoading(true); try { // Fire both fetches in parallel const topPromise = getTopSongs(artist.name); const similarPromise = getSimilarSongs2(artist.id, 50); // Start playing as soon as top songs arrive const top = await topPromise; if (top.length > 0) { const firstTrack = songToTrack(top[0]); playTrack(firstTrack, [firstTrack]); setRadioLoading(false); // Enqueue remaining tracks when similar songs arrive const similar = await similarPromise; const remaining = [...top.slice(1), ...similar].map(songToTrack); if (remaining.length > 0) enqueue(remaining); } else { // No top songs — fall back to similar const similar = await similarPromise; if (similar.length > 0) { const tracks = similar.map(songToTrack); playTrack(tracks[0], tracks); } else { alert(t('artistDetail.noRadio')); } setRadioLoading(false); } } catch (e) { console.error('Radio start failed', e); setRadioLoading(false); } }; const playTopSongWithContinuation = async (startIndex: number) => { if (!artist || albums.length === 0) return; setPlayAllLoading(true); try { // Get all artist tracks ordered by album and track number const allTracks = await fetchAllTracks(); // Top songs from clicked index onward const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack); // Track IDs for deduplication const topSongIds = new Set(topSongs.map(s => s.id)); // Filter remaining tracks to exclude top songs (prevent duplicates) const remainingTracks = allTracks.filter(t => !topSongIds.has(t.id)); // Build queue: remaining top songs + rest of artist catalog const queue = [...topTracksFromIndex, ...remainingTracks]; if (queue.length > 0) { playTrack(queue[0], queue); } } finally { setPlayAllLoading(false); } }; const handleImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; if (!file || !artist) return; setUploading(true); try { await uploadArtistImage(artist.id, file); const coverId = artist.coverArt || artist.id; await invalidateCoverArt(coverId); // Also invalidate with bare artist.id in case coverArt differs if (artist.coverArt && artist.coverArt !== artist.id) { await invalidateCoverArt(artist.id); } setCoverRevision(r => r + 1); showToast(t('artistDetail.uploadImage')); } catch (err) { showToast( typeof err === 'string' ? err : err instanceof Error ? err.message : t('artistDetail.uploadImageError'), 4000, 'error', ); } finally { setUploading(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)}`; const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({ id: sa.id, name: sa.name, albumCount: sa.albumCount, })); const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0; const showLastfmSimilar = lastfmIsConfigured() && (!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) && (similarLoading || similarArtists.length > 0); const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar; return (
{lightboxOpen && ( setLightboxOpen(false)} /> )}
{coverId ? ( ) : ( )} {/* Upload overlay */}
{ e.stopPropagation(); imageInputRef.current?.click(); }} > {uploading ? : }

{artist.name}

{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
{t('entityRating.artistShort')}
{(info?.lastFmUrl || artist.name) && (
{info?.lastFmUrl && ( )}
)}
{albums.length > 0 && ( <> )} {albums.length > 0 && (() => { const progress = id ? bulkProgress[id] : undefined; const isDone = progress && progress.done === progress.total; const isDownloading = progress && !isDone; return ( ); })()}
{/* Biography — sanitized HTML from server */} {info?.biography && (

{t('nowPlaying.aboutArtist')}

{(info.largeImageUrl || coverId) && ( {artist.name} { (e.target as HTMLImageElement).style.display = 'none'; }} /> )}
)} {/* Top Songs */} {topSongs.length > 0 && ( <>

{t('artistDetail.topTracks')}

#
{t('artistDetail.trackTitle')}
{t('artistDetail.trackAlbum')}
{t('artistDetail.trackDuration')}
{topSongs.map((song, idx) => { const track = songToTrack(song); return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; playTopSongWithContinuation(idx); }} onContextMenu={(e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }} >
{ e.stopPropagation(); playTopSongWithContinuation(idx); }}> {currentTrack?.id === song.id && isPlaying &&
} {idx + 1}
{song.coverArt && ( { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> )}
{song.title}
{song.album}
{formatDuration(song.duration)}
); })}
)} {showSimilarSection && ( <>

{t('artistDetail.similarArtists')}

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

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

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

{t('artistDetail.noAlbums')}

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

{t('artistDetail.featuredOn')}

{featuredLoading ? (
{[...Array(4)].map((_, i) => (
))}
) : (
{featuredAlbums.map(a => )}
)} )}
); }