feat: enhance artist page with popular songs and artist bio sections, update Last.fm API integration
This commit is contained in:
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_COMMIT_SHA=f0f3d5a
|
||||
NEXT_PUBLIC_COMMIT_SHA=3ca162e
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Album, Artist } from '@/lib/navidrome';
|
||||
import { Album, Artist, Song } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { AlbumArtwork } from '@/app/components/album-artwork';
|
||||
import { PopularSongs } from '@/app/components/PopularSongs';
|
||||
import { SimilarArtists } from '@/app/components/SimilarArtists';
|
||||
import { ArtistBio } from '@/app/components/ArtistBio';
|
||||
import Image from 'next/image';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Heart, Play } from 'lucide-react';
|
||||
@@ -17,6 +20,7 @@ export default function ArtistPage() {
|
||||
const { artist: artistId } = useParams();
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [artistAlbums, setArtistAlbums] = useState<Album[]>([]);
|
||||
const [popularSongs, setPopularSongs] = useState<Song[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [artist, setArtist] = useState<Artist | null>(null);
|
||||
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
|
||||
@@ -29,11 +33,19 @@ export default function ArtistPage() {
|
||||
const fetchArtistData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (artistId) {
|
||||
if (artistId && api) {
|
||||
const artistData = await getArtist(artistId as string);
|
||||
setArtist(artistData.artist);
|
||||
setArtistAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
|
||||
// Fetch popular songs for the artist
|
||||
try {
|
||||
const songs = await api.getArtistTopSongs(artistData.artist.name, 10);
|
||||
setPopularSongs(songs);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch popular songs:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch artist data:', error);
|
||||
@@ -42,7 +54,7 @@ export default function ArtistPage() {
|
||||
};
|
||||
|
||||
fetchArtistData();
|
||||
}, [artistId, getArtist]);
|
||||
}, [artistId, getArtist, api]);
|
||||
|
||||
const handleStar = async () => {
|
||||
if (!artist) return;
|
||||
@@ -136,9 +148,17 @@ export default function ArtistPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About Section */}
|
||||
<ArtistBio artistName={artist.name} />
|
||||
|
||||
{/* Popular Songs Section */}
|
||||
{popularSongs.length > 0 && (
|
||||
<PopularSongs songs={popularSongs} artistName={artist.name} />
|
||||
)}
|
||||
|
||||
{/* Albums Section */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Albums</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Discography</h2>
|
||||
<ScrollArea>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 pb-4">
|
||||
{artistAlbums.map((album) => (
|
||||
@@ -155,6 +175,12 @@ export default function ArtistPage() {
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Similar Artists Section */}
|
||||
<SimilarArtists artistName={artist.name} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
106
app/components/ArtistBio.tsx
Normal file
106
app/components/ArtistBio.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { lastFmAPI } from '@/lib/lastfm-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface ArtistBioProps {
|
||||
artistName: string;
|
||||
}
|
||||
|
||||
export function ArtistBio({ artistName }: ArtistBioProps) {
|
||||
const [bio, setBio] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [lastFmUrl, setLastFmUrl] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchArtistInfo = async () => {
|
||||
if (!lastFmAPI.isAvailable()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const artistInfo = await lastFmAPI.getArtistInfo(artistName);
|
||||
if (artistInfo?.bio?.summary) {
|
||||
// Clean up the bio text (remove HTML tags and Last.fm links)
|
||||
let cleanBio = artistInfo.bio.summary
|
||||
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.trim();
|
||||
|
||||
// Remove the "Read more on Last.fm" part
|
||||
cleanBio = cleanBio.replace(/Read more on Last\.fm.*$/i, '').trim();
|
||||
|
||||
setBio(cleanBio);
|
||||
setLastFmUrl(`https://www.last.fm/music/${encodeURIComponent(artistName)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch artist bio:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchArtistInfo();
|
||||
}, [artistName]);
|
||||
|
||||
if (!lastFmAPI.isAvailable() || loading || !bio) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldTruncate = bio.length > 300;
|
||||
const displayBio = shouldTruncate && !expanded ? bio.substring(0, 300) + '...' : bio;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">About</h2>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{displayBio}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldTruncate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs h-7 px-2"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{lastFmUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="text-xs h-7 px-2"
|
||||
>
|
||||
<a
|
||||
href={lastFmUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
Last.fm
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
app/components/PopularSongs.tsx
Normal file
147
app/components/PopularSongs.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
import { Song } from '@/lib/navidrome';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Play, Heart } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
|
||||
interface PopularSongsProps {
|
||||
songs: Song[];
|
||||
artistName: string;
|
||||
}
|
||||
|
||||
export function PopularSongs({ songs, artistName }: PopularSongsProps) {
|
||||
const { playTrack } = useAudioPlayer();
|
||||
const { starItem, unstarItem } = useNavidrome();
|
||||
const [songStates, setSongStates] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
songs.forEach(song => {
|
||||
initial[song.id] = !!song.starred;
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
const songToTrack = (song: Song) => {
|
||||
if (!api) {
|
||||
throw new Error('Navidrome API not configured');
|
||||
}
|
||||
return {
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api.getStreamUrl(song.id),
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
};
|
||||
};
|
||||
|
||||
const handlePlaySong = async (song: Song) => {
|
||||
try {
|
||||
const track = songToTrack(song);
|
||||
playTrack(track, true);
|
||||
} catch (error) {
|
||||
console.error('Failed to play song:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStar = async (song: Song) => {
|
||||
try {
|
||||
const isStarred = songStates[song.id];
|
||||
if (isStarred) {
|
||||
await unstarItem(song.id, 'song');
|
||||
setSongStates(prev => ({ ...prev, [song.id]: false }));
|
||||
} else {
|
||||
await starItem(song.id, 'song');
|
||||
setSongStates(prev => ({ ...prev, [song.id]: true }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to star/unstar song:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (songs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Popular Songs</h2>
|
||||
<div className="space-y-2">
|
||||
{songs.map((song, index) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="flex items-center space-x-4 p-3 rounded-lg hover:bg-muted/50 group"
|
||||
>
|
||||
{/* Rank */}
|
||||
<div className="w-8 text-sm text-muted-foreground text-center">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Album Art */}
|
||||
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden flex-shrink-0">
|
||||
{song.coverArt && api && (
|
||||
<img
|
||||
src={api.getCoverArtUrl(song.coverArt, 96)}
|
||||
alt={song.album}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-white hover:bg-white/20"
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Song Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{song.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{song.album}</div>
|
||||
</div>
|
||||
|
||||
{/* Play Count */}
|
||||
{song.playCount && song.playCount > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{song.playCount.toLocaleString()} plays
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
<div className="text-xs text-muted-foreground w-12 text-right">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
|
||||
{/* Star Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handleToggleStar(song)}
|
||||
>
|
||||
<Heart
|
||||
className={`h-4 w-4 ${songStates[song.id] ? 'text-red-500 fill-red-500' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
app/components/SimilarArtists.tsx
Normal file
93
app/components/SimilarArtists.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { lastFmAPI } from '@/lib/lastfm-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface SimilarArtist {
|
||||
name: string;
|
||||
url: string;
|
||||
image?: Array<{
|
||||
'#text': string;
|
||||
size: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SimilarArtistsProps {
|
||||
artistName: string;
|
||||
}
|
||||
|
||||
export function SimilarArtists({ artistName }: SimilarArtistsProps) {
|
||||
const [similarArtists, setSimilarArtists] = useState<SimilarArtist[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSimilarArtists = async () => {
|
||||
if (!lastFmAPI.isAvailable()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const similar = await lastFmAPI.getSimilarArtists(artistName, 6);
|
||||
if (similar?.artist) {
|
||||
setSimilarArtists(similar.artist);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch similar artists:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSimilarArtists();
|
||||
}, [artistName]);
|
||||
|
||||
const getArtistImage = (artist: SimilarArtist): string => {
|
||||
if (!artist.image || artist.image.length === 0) {
|
||||
return '/default-user.jpg';
|
||||
}
|
||||
|
||||
// Try to get medium or large image
|
||||
const mediumImage = artist.image.find(img => img.size === 'medium' || img.size === 'large');
|
||||
const anyImage = artist.image[artist.image.length - 1]; // Fallback to last image
|
||||
|
||||
return mediumImage?.['#text'] || anyImage?.['#text'] || '/default-user.jpg';
|
||||
};
|
||||
|
||||
if (!lastFmAPI.isAvailable() || loading || similarArtists.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Fans also like</h2>
|
||||
<ScrollArea>
|
||||
<div className="flex space-x-4 pb-4">
|
||||
{similarArtists.map((artist) => (
|
||||
<Link
|
||||
key={artist.name}
|
||||
href={`/artist/${encodeURIComponent(artist.name)}`}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<div className="w-32 space-y-2 group cursor-pointer">
|
||||
<div className="relative w-32 h-32 bg-muted rounded-full overflow-hidden">
|
||||
<img
|
||||
src={getArtistImage(artist)}
|
||||
alt={artist.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
||||
{artist.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const CHANGELOG = [
|
||||
'Improved search and browsing experience',
|
||||
'Added history tracking for played songs',
|
||||
'New Library Artist Page',
|
||||
'Enhanced audio player with better controls',
|
||||
'Artist page with top songs and albums',
|
||||
'Added settings page for customization options',
|
||||
'Introduced Whats New popup for version updates',
|
||||
'Improved UI consistency with new Badge component',
|
||||
|
||||
@@ -55,7 +55,7 @@ export function Sidebar({ className, playlists, collapsed = false, onToggle }: S
|
||||
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className={`space-y-4 py-4 ${collapsed ? "pt-6" : "" }`}>
|
||||
<div className="px-3 py-2">
|
||||
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
|
||||
Discover
|
||||
|
||||
@@ -443,37 +443,6 @@ const SettingsPage = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaCog className="w-5 h-5" />
|
||||
Application Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
General application preferences and setup
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label>First-Time Setup</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
localStorage.removeItem('onboarding-completed');
|
||||
window.location.reload();
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Run Setup Wizard Again
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Re-run the initial setup wizard to configure your preferences from scratch
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
154
lib/lastfm-api.ts
Normal file
154
lib/lastfm-api.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
interface LastFmCredentials {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
sessionKey?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface LastFmArtistInfo {
|
||||
name: string;
|
||||
bio?: {
|
||||
summary: string;
|
||||
content: string;
|
||||
};
|
||||
stats?: {
|
||||
listeners: string;
|
||||
playcount: string;
|
||||
};
|
||||
similar?: {
|
||||
artist: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
image: Array<{
|
||||
'#text': string;
|
||||
size: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
tags?: {
|
||||
tag: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
image?: Array<{
|
||||
'#text': string;
|
||||
size: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface LastFmTopTracks {
|
||||
track: Array<{
|
||||
name: string;
|
||||
playcount: string;
|
||||
listeners: string;
|
||||
artist: {
|
||||
name: string;
|
||||
mbid: string;
|
||||
url: string;
|
||||
};
|
||||
image: Array<{
|
||||
'#text': string;
|
||||
size: string;
|
||||
}>;
|
||||
'@attr': {
|
||||
rank: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export class LastFmAPI {
|
||||
private baseUrl = 'https://ws.audioscrobbler.com/2.0/';
|
||||
|
||||
private getCredentials(): LastFmCredentials | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const stored = localStorage.getItem('lastfm-credentials');
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async makeRequest(method: string, params: Record<string, string>): Promise<any> {
|
||||
const credentials = this.getCredentials();
|
||||
if (!credentials?.apiKey) {
|
||||
throw new Error('No Last.fm API key available');
|
||||
}
|
||||
|
||||
const url = new URL(this.baseUrl);
|
||||
url.searchParams.append('method', method);
|
||||
url.searchParams.append('api_key', credentials.apiKey);
|
||||
url.searchParams.append('format', 'json');
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`Last.fm API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.message || 'Last.fm API error');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getArtistInfo(artistName: string): Promise<LastFmArtistInfo | null> {
|
||||
try {
|
||||
const data = await this.makeRequest('artist.getInfo', {
|
||||
artist: artistName,
|
||||
autocorrect: '1'
|
||||
});
|
||||
|
||||
return data.artist || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch artist info from Last.fm:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getArtistTopTracks(artistName: string, limit: number = 10): Promise<LastFmTopTracks | null> {
|
||||
try {
|
||||
const data = await this.makeRequest('artist.getTopTracks', {
|
||||
artist: artistName,
|
||||
limit: limit.toString(),
|
||||
autocorrect: '1'
|
||||
});
|
||||
|
||||
return data.toptracks || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch artist top tracks from Last.fm:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getSimilarArtists(artistName: string, limit: number = 6): Promise<LastFmArtistInfo['similar'] | null> {
|
||||
try {
|
||||
const data = await this.makeRequest('artist.getSimilar', {
|
||||
artist: artistName,
|
||||
limit: limit.toString(),
|
||||
autocorrect: '1'
|
||||
});
|
||||
|
||||
return data.similarartists || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch similar artists from Last.fm:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
const credentials = this.getCredentials();
|
||||
return !!credentials?.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
export const lastFmAPI = new LastFmAPI();
|
||||
@@ -503,6 +503,26 @@ class NavidromeAPI {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getArtistTopSongs(artistName: string, limit: number = 10): Promise<Song[]> {
|
||||
try {
|
||||
// Search for songs by the artist and return them sorted by play count
|
||||
const searchResult = await this.search2(artistName, 0, 0, limit * 3);
|
||||
|
||||
// Filter songs that are actually by this artist (exact match)
|
||||
const artistSongs = searchResult.songs.filter(song =>
|
||||
song.artist.toLowerCase() === artistName.toLowerCase()
|
||||
);
|
||||
|
||||
// Sort by play count (descending) and limit results
|
||||
return artistSongs
|
||||
.sort((a, b) => (b.playCount || 0) - (a.playCount || 0))
|
||||
.slice(0, limit);
|
||||
} catch (error) {
|
||||
console.error('Failed to get artist top songs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance management
|
||||
|
||||
Reference in New Issue
Block a user