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';
|
'use client';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
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 { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
import { AlbumArtwork } from '@/app/components/album-artwork';
|
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 Image from 'next/image';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Heart, Play } from 'lucide-react';
|
import { Heart, Play } from 'lucide-react';
|
||||||
@@ -17,6 +20,7 @@ export default function ArtistPage() {
|
|||||||
const { artist: artistId } = useParams();
|
const { artist: artistId } = useParams();
|
||||||
const [isStarred, setIsStarred] = useState(false);
|
const [isStarred, setIsStarred] = useState(false);
|
||||||
const [artistAlbums, setArtistAlbums] = useState<Album[]>([]);
|
const [artistAlbums, setArtistAlbums] = useState<Album[]>([]);
|
||||||
|
const [popularSongs, setPopularSongs] = useState<Song[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [artist, setArtist] = useState<Artist | null>(null);
|
const [artist, setArtist] = useState<Artist | null>(null);
|
||||||
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
|
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
|
||||||
@@ -29,11 +33,19 @@ export default function ArtistPage() {
|
|||||||
const fetchArtistData = async () => {
|
const fetchArtistData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (artistId) {
|
if (artistId && api) {
|
||||||
const artistData = await getArtist(artistId as string);
|
const artistData = await getArtist(artistId as string);
|
||||||
setArtist(artistData.artist);
|
setArtist(artistData.artist);
|
||||||
setArtistAlbums(artistData.albums);
|
setArtistAlbums(artistData.albums);
|
||||||
setIsStarred(!!artistData.artist.starred);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch artist data:', error);
|
console.error('Failed to fetch artist data:', error);
|
||||||
@@ -42,7 +54,7 @@ export default function ArtistPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchArtistData();
|
fetchArtistData();
|
||||||
}, [artistId, getArtist]);
|
}, [artistId, getArtist, api]);
|
||||||
|
|
||||||
const handleStar = async () => {
|
const handleStar = async () => {
|
||||||
if (!artist) return;
|
if (!artist) return;
|
||||||
@@ -135,10 +147,18 @@ export default function ArtistPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* About Section */}
|
||||||
|
<ArtistBio artistName={artist.name} />
|
||||||
|
|
||||||
|
{/* Popular Songs Section */}
|
||||||
|
{popularSongs.length > 0 && (
|
||||||
|
<PopularSongs songs={popularSongs} artistName={artist.name} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Albums Section */}
|
{/* Albums Section */}
|
||||||
<div className="space-y-4">
|
<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>
|
<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">
|
<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) => (
|
{artistAlbums.map((album) => (
|
||||||
@@ -155,6 +175,12 @@ export default function ArtistPage() {
|
|||||||
<ScrollBar orientation="horizontal" />
|
<ScrollBar orientation="horizontal" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Similar Artists Section */}
|
||||||
|
<SimilarArtists artistName={artist.name} />
|
||||||
</div>
|
</div>
|
||||||
</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',
|
'Improved search and browsing experience',
|
||||||
'Added history tracking for played songs',
|
'Added history tracking for played songs',
|
||||||
'New Library Artist Page',
|
'New Library Artist Page',
|
||||||
'Enhanced audio player with better controls',
|
'Artist page with top songs and albums',
|
||||||
'Added settings page for customization options',
|
'Added settings page for customization options',
|
||||||
'Introduced Whats New popup for version updates',
|
'Introduced Whats New popup for version updates',
|
||||||
'Improved UI consistency with new Badge component',
|
'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" />}
|
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className={`space-y-4 py-4 ${collapsed ? "pt-6" : "" }`}>
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
|
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
|
||||||
Discover
|
Discover
|
||||||
|
|||||||
@@ -443,37 +443,6 @@ const SettingsPage = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<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 [];
|
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
|
// Singleton instance management
|
||||||
|
|||||||
Reference in New Issue
Block a user