mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(artist-detail): G.84 — extract helpers + suggestion cover + 2 data hooks + play utilities (cluster) (#651)
Five-cut cluster opening the ArtistDetail refactor. 944 → 631 LOC
(−313).
artistDetailHelpers — formatDuration (M:SS) + sanitizeHtml (strip
script/style/iframe/etc tags + onXxx + javascript: / data: hrefs).
ArtistSuggestionTrackCover — tiny CachedImage wrapper for the
32×32 cover thumbnail in the suggestions tracklist.
useArtistDetailData — owns the page's three primary fetch effects:
getArtist + getTopSongs on artist id change, getArtistInfo on id +
audiomuseNavidromeEnabled change, and the background "Also Featured
On" search that derives albums from search results not in the
artist's own album set. Exposes artist / setArtist / albums /
topSongs / info / featuredAlbums / loading flags + isStarred for
the star button.
useArtistSimilarArtists — owns the two parallel similar-artist
effects (Last.fm primary path when AudioMuse is off; Last.fm
fallback when AudioMuse is on but returned nothing) plus the
audiomuse-positive-result reset. Returns { similarArtists,
similarLoading }.
runArtistDetailPlay — three play orchestrators that share the
fetchAllTracks helper: runArtistDetailPlayAll, runArtistDetailShuffle,
runArtistDetailStartRadio (with the no-radio fallback alert). All
take deps objects so the page just delegates state setters.
ArtistDetail drops the now-unused direct search / getArtistInfo /
getTopSongs / getSimilarSongs2 / getArtist / lastfmGetSimilarArtists
imports. Pure code move otherwise.
This commit is contained in:
committed by
GitHub
parent
c207f748da
commit
ba0bf8aa9d
@@ -0,0 +1,17 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||||
|
import CachedImage from '../CachedImage';
|
||||||
|
|
||||||
|
export default function ArtistSuggestionTrackCover({ coverArt, album }: { coverArt: string; album: string }) {
|
||||||
|
const src = useMemo(() => buildCoverArtUrl(coverArt, 64), [coverArt]);
|
||||||
|
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 64), [coverArt]);
|
||||||
|
return (
|
||||||
|
<CachedImage
|
||||||
|
src={src}
|
||||||
|
cacheKey={cacheKey}
|
||||||
|
alt={album}
|
||||||
|
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { search } from '../api/subsonicSearch';
|
||||||
|
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
||||||
|
import type {
|
||||||
|
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
|
||||||
|
} from '../api/subsonicTypes';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
|
export interface ArtistDetailDataResult {
|
||||||
|
artist: SubsonicArtist | null;
|
||||||
|
setArtist: React.Dispatch<React.SetStateAction<SubsonicArtist | null>>;
|
||||||
|
albums: SubsonicAlbum[];
|
||||||
|
topSongs: SubsonicSong[];
|
||||||
|
info: SubsonicArtistInfo | null;
|
||||||
|
featuredAlbums: SubsonicAlbum[];
|
||||||
|
loading: boolean;
|
||||||
|
artistInfoLoading: boolean;
|
||||||
|
featuredLoading: boolean;
|
||||||
|
isStarred: boolean;
|
||||||
|
setIsStarred: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArtistDetailData(id: string | undefined): ArtistDetailDataResult {
|
||||||
|
const audiomuseNavidromeEnabled = useAuthStore(
|
||||||
|
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||||
|
);
|
||||||
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
|
||||||
|
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||||
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
|
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
|
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
|
||||||
|
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isStarred, setIsStarred] = useState(false);
|
||||||
|
const [artistInfoLoading, setArtistInfoLoading] = useState(false);
|
||||||
|
const [featuredLoading, setFeaturedLoading] = useState(false);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// "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<string, SubsonicAlbum>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [artist?.id, musicLibraryFilterVersion]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
artist, setArtist, albums, topSongs, info, featuredAlbums,
|
||||||
|
loading, artistInfoLoading, featuredLoading,
|
||||||
|
isStarred, setIsStarred,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
||||||
|
import { search } from '../api/subsonicSearch';
|
||||||
|
import type { SubsonicArtist, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
|
export interface ArtistSimilarArtistsResult {
|
||||||
|
similarArtists: SubsonicArtist[];
|
||||||
|
similarLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the "Similar Artists" list for the current artist:
|
||||||
|
* - Default: Last.fm getSimilar → server search for each name → keep first exact match.
|
||||||
|
* - With audiomuseNavidromeEnabled on: prefer info.similarArtist; fall back to Last.fm
|
||||||
|
* when the server returns nothing and Last.fm is configured.
|
||||||
|
*/
|
||||||
|
export function useArtistSimilarArtists(
|
||||||
|
artist: SubsonicArtist | null,
|
||||||
|
info: SubsonicArtistInfo | null,
|
||||||
|
artistInfoLoading: boolean,
|
||||||
|
): ArtistSimilarArtistsResult {
|
||||||
|
const audiomuseNavidromeEnabled = useAuthStore(
|
||||||
|
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||||
|
);
|
||||||
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
|
||||||
|
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||||
|
const [similarLoading, setSimilarLoading] = useState(false);
|
||||||
|
|
||||||
|
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<string>([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));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [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<string>([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);
|
||||||
|
}
|
||||||
|
}, [artist?.id, audiomuseNavidromeEnabled, info?.similarArtist?.length]);
|
||||||
|
|
||||||
|
return { similarArtists, similarLoading };
|
||||||
|
}
|
||||||
+22
-259
@@ -1,9 +1,7 @@
|
|||||||
import { uploadArtistImage } from '../api/subsonicPlaylists';
|
import { uploadArtistImage } from '../api/subsonicPlaylists';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||||
import { search } from '../api/subsonicSearch';
|
|
||||||
import { getAlbum } from '../api/subsonicLibrary';
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2 } from '../api/subsonicArtists';
|
|
||||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||||
@@ -21,7 +19,7 @@ import { useOfflineStore } from '../store/offlineStore';
|
|||||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
import { lastfmIsConfigured } from '../api/lastfm';
|
||||||
import LastfmIcon from '../components/LastfmIcon';
|
import LastfmIcon from '../components/LastfmIcon';
|
||||||
import { invalidateCoverArt } from '../utils/imageCache';
|
import { invalidateCoverArt } from '../utils/imageCache';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
@@ -30,62 +28,28 @@ import { extractCoverColors } from '../utils/dynamicColors';
|
|||||||
import StarRating from '../components/StarRating';
|
import StarRating from '../components/StarRating';
|
||||||
import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
|
import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
import { formatDuration, sanitizeHtml } from '../utils/artistDetailHelpers';
|
||||||
const m = Math.floor(seconds / 60);
|
import ArtistSuggestionTrackCover from '../components/artistDetail/ArtistSuggestionTrackCover';
|
||||||
const s = seconds % 60;
|
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
||||||
}
|
import {
|
||||||
|
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
|
||||||
function ArtistSuggestionTrackCover({ coverArt, album }: { coverArt: string; album: string }) {
|
} from '../utils/runArtistDetailPlay';
|
||||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 64), [coverArt]);
|
|
||||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 64), [coverArt]);
|
|
||||||
return (
|
|
||||||
<CachedImage
|
|
||||||
src={src}
|
|
||||||
cacheKey={cacheKey}
|
|
||||||
alt={album}
|
|
||||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
|
||||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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() {
|
export default function ArtistDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
const {
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
artist, setArtist, albums, topSongs, info, featuredAlbums,
|
||||||
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
|
loading, artistInfoLoading, featuredLoading,
|
||||||
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
|
isStarred, setIsStarred,
|
||||||
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
} = useArtistDetailData(id);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [radioLoading, setRadioLoading] = useState(false);
|
const [radioLoading, setRadioLoading] = useState(false);
|
||||||
const [playAllLoading, setPlayAllLoading] = useState(false);
|
const [playAllLoading, setPlayAllLoading] = useState(false);
|
||||||
const [isStarred, setIsStarred] = useState(false);
|
|
||||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
const { similarArtists, similarLoading } = useArtistSimilarArtists(artist, info, artistInfoLoading);
|
||||||
const [similarLoading, setSimilarLoading] = useState(false);
|
|
||||||
const [artistInfoLoading, setArtistInfoLoading] = useState(false);
|
|
||||||
const [featuredLoading, setFeaturedLoading] = useState(false);
|
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [bioExpanded, setBioExpanded] = useState(false);
|
const [bioExpanded, setBioExpanded] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -123,47 +87,9 @@ export default function ArtistDetail() {
|
|||||||
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
|
||||||
let cancelled = false;
|
|
||||||
setLoading(true);
|
|
||||||
setInfo(null);
|
|
||||||
setTopSongs([]);
|
|
||||||
setFeaturedAlbums([]);
|
|
||||||
setAvatarGlow('');
|
setAvatarGlow('');
|
||||||
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]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
||||||
@@ -192,112 +118,6 @@ export default function ArtistDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// "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<string, SubsonicAlbum>();
|
|
||||||
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<string>([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<string>([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) => {
|
const openLink = (url: string, key: string) => {
|
||||||
open(url);
|
open(url);
|
||||||
setOpenedLink(key);
|
setOpenedLink(key);
|
||||||
@@ -317,70 +137,11 @@ export default function ArtistDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchAllTracks = async () => {
|
const handlePlayAll = () => runArtistDetailPlayAll({ albums, setPlayAllLoading, playTrack });
|
||||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
const handleShuffle = () => runArtistDetailShuffle({ albums, setPlayAllLoading, playTrack });
|
||||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
const handleStartRadio = () => {
|
||||||
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;
|
if (!artist) return;
|
||||||
setRadioLoading(true);
|
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
|
||||||
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 handleShareArtist = async () => {
|
const handleShareArtist = async () => {
|
||||||
@@ -399,7 +160,9 @@ export default function ArtistDetail() {
|
|||||||
setPlayAllLoading(true);
|
setPlayAllLoading(true);
|
||||||
try {
|
try {
|
||||||
// Get all artist tracks ordered by album and track number
|
// Get all artist tracks ordered by album and track number
|
||||||
const allTracks = await fetchAllTracks();
|
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));
|
||||||
|
const allTracks = sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||||
|
|
||||||
// Top songs from clicked index onward
|
// Top songs from clicked index onward
|
||||||
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
|
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
|
||||||
@@ -408,7 +171,7 @@ export default function ArtistDetail() {
|
|||||||
const topSongIds = new Set(topSongs.map(s => s.id));
|
const topSongIds = new Set(topSongs.map(s => s.id));
|
||||||
|
|
||||||
// Filter remaining tracks to exclude top songs (prevent duplicates)
|
// Filter remaining tracks to exclude top songs (prevent duplicates)
|
||||||
const remainingTracks = allTracks.filter(t => !topSongIds.has(t.id));
|
const remainingTracks = allTracks.filter(tr => !topSongIds.has(tr.id));
|
||||||
|
|
||||||
// Build queue: remaining top songs + rest of artist catalog
|
// Build queue: remaining top songs + rest of artist catalog
|
||||||
const queue = [...topTracksFromIndex, ...remainingTracks];
|
const queue = [...topTracksFromIndex, ...remainingTracks];
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export 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 */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
|
import { getSimilarSongs2, getTopSongs } from '../api/subsonicArtists';
|
||||||
|
import type { SubsonicAlbum, SubsonicArtist } from '../api/subsonicTypes';
|
||||||
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
|
import { songToTrack } from './songToTrack';
|
||||||
|
|
||||||
|
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunArtistDetailPlayDeps {
|
||||||
|
albums: SubsonicAlbum[];
|
||||||
|
setPlayAllLoading: (v: boolean) => void;
|
||||||
|
playTrack: (track: Track, queue: Track[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||||
|
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||||
|
if (albums.length === 0) return;
|
||||||
|
setPlayAllLoading(true);
|
||||||
|
try {
|
||||||
|
const tracks = await fetchAllTracks(albums);
|
||||||
|
if (tracks.length > 0) playTrack(tracks[0], tracks);
|
||||||
|
} finally {
|
||||||
|
setPlayAllLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||||
|
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||||
|
if (albums.length === 0) return;
|
||||||
|
setPlayAllLoading(true);
|
||||||
|
try {
|
||||||
|
const tracks = await fetchAllTracks(albums);
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||||
|
playTrack(shuffled[0], shuffled);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPlayAllLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunArtistDetailStartRadioDeps {
|
||||||
|
artist: SubsonicArtist;
|
||||||
|
t: TFunction;
|
||||||
|
setRadioLoading: (v: boolean) => void;
|
||||||
|
playTrack: (track: Track, queue: Track[]) => void;
|
||||||
|
enqueue: (tracks: Track[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runArtistDetailStartRadio(deps: RunArtistDetailStartRadioDeps): Promise<void> {
|
||||||
|
const { artist, t, setRadioLoading, playTrack, enqueue } = deps;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user