Update logic of artist avatar fetching

This commit is contained in:
2025-11-07 21:00:03 +11:00
parent c885ef4989
commit c60a655e76
5 changed files with 133 additions and 39 deletions
+51
View File
@@ -482,6 +482,57 @@ async def get_artist_all_songs(
) )
@router.get("/{artist_name}/image")
async def get_artist_image(artist_name: str, db: AsyncSession = Depends(get_db)):
"""Get artist image (returns image file or 404)
Priority:
1. Cached artist image from online APIs
2. Thumbnail from artist's first song
3. 404 (frontend will show default icon)
"""
from fastapi.responses import FileResponse, Response
from fastapi import HTTPException
# 1. Try to get from artist info cache
cached_info = get_cached_artist_info(artist_name)
if cached_info and cached_info.image:
# Check if it's a local cache path
if not cached_info.image.startswith('http'):
# It's a relative path like "cache/artist_images/artist_name.jpg"
image_path = os.path.join(settings.BASE_DIR, "data", cached_info.image)
if os.path.exists(image_path):
return FileResponse(image_path, media_type="image/jpeg")
# If it's an HTTP URL (song thumbnail), redirect to it
if cached_info.image.startswith('http'):
return Response(status_code=307, headers={"Location": cached_info.image})
# 2. If not in cache, try to get from artist's songs
result = await db.execute(
select(Music)
.where(Music.artist == artist_name)
.where(Music.thumbnail.isnot(None))
.where(Music.thumbnail != "")
.limit(1)
)
song_with_thumbnail = result.scalar_one_or_none()
if song_with_thumbnail and song_with_thumbnail.thumbnail:
# Check if it's a local thumbnail
if not song_with_thumbnail.thumbnail.startswith('http'):
thumbnail_path = os.path.join(settings.MUSIC_DIR, song_with_thumbnail.thumbnail)
if os.path.exists(thumbnail_path):
return FileResponse(thumbnail_path, media_type="image/jpeg")
# If it's an HTTP URL, redirect
if song_with_thumbnail.thumbnail.startswith('http'):
return Response(status_code=307, headers={"Location": song_with_thumbnail.thumbnail})
# 3. No image found - return 404
raise HTTPException(status_code=404, detail="Artist image not found")
@router.get("/{artist_name}", response_model=List[MusicSchema]) @router.get("/{artist_name}", response_model=List[MusicSchema])
async def get_artist_songs( async def get_artist_songs(
artist_name: str, artist_name: str,
+24 -14
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Play, AlertCircle, Info, Search, LayoutList, LayoutGrid, ArrowUpDown, Loader2 } from 'lucide-react' import { Play, AlertCircle, Info, Search, LayoutList, LayoutGrid, ArrowUpDown, Loader2 } from 'lucide-react'
import MusicDetailModal from './music/MusicDetailModal' import MusicDetailModal from './music/MusicDetailModal'
import { getMusicThumbnail } from '@/lib/utils'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -187,17 +188,22 @@ export default function MusicLibrary({ onPlayMusic, currentMusic, onPlayNext }:
className="flex items-center gap-2 md:gap-4 p-2 md:p-3 rounded-lg hover:bg-accent transition-colors cursor-pointer" className="flex items-center gap-2 md:gap-4 p-2 md:p-3 rounded-lg hover:bg-accent transition-colors cursor-pointer"
onClick={() => music.file_exists && onPlayMusic(music, filteredMusic)} onClick={() => music.file_exists && onPlayMusic(music, filteredMusic)}
> >
{music.thumbnail ? ( {getMusicThumbnail(music) ? (
<img <img
src={music.thumbnail.startsWith('http') ? music.thumbnail : `/music/${music.thumbnail}`} src={getMusicThumbnail(music)!}
alt={music.title} alt={music.title}
className="w-12 h-12 rounded object-cover flex-shrink-0" className="w-12 h-12 rounded object-cover flex-shrink-0"
onError={(e) => {
// Fallback to default icon if image fails to load
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.nextElementSibling?.classList.remove('hidden')
}}
/> />
) : ( ) : null}
<div className="w-12 h-12 rounded bg-secondary flex items-center justify-center flex-shrink-0"> <div className={`w-12 h-12 rounded bg-secondary flex items-center justify-center flex-shrink-0 ${getMusicThumbnail(music) ? 'hidden' : ''}`}>
<Play className="h-6 w-6 text-muted-foreground" /> <Play className="h-6 w-6 text-muted-foreground" />
</div> </div>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -243,17 +249,21 @@ export default function MusicLibrary({ onPlayMusic, currentMusic, onPlayNext }:
className="group relative flex flex-col gap-2 p-3 rounded-lg hover:bg-accent transition-colors" className="group relative flex flex-col gap-2 p-3 rounded-lg hover:bg-accent transition-colors"
> >
<div className="relative aspect-square"> <div className="relative aspect-square">
{music.thumbnail ? ( {getMusicThumbnail(music) ? (
<img <img
src={music.thumbnail.startsWith('http') ? music.thumbnail : `/music/${music.thumbnail}`} src={getMusicThumbnail(music)!}
alt={music.title} alt={music.title}
className="w-full h-full rounded object-cover" className="w-full h-full rounded object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.nextElementSibling?.classList.remove('hidden')
}}
/> />
) : ( ) : null}
<div className="w-full h-full rounded bg-secondary flex items-center justify-center"> <div className={`w-full h-full rounded bg-secondary flex items-center justify-center ${getMusicThumbnail(music) ? 'hidden' : ''}`}>
<Play className="h-12 w-12 text-muted-foreground" /> <Play className="h-12 w-12 text-muted-foreground" />
</div> </div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded flex items-center justify-center gap-2"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded flex items-center justify-center gap-2">
<Button <Button
size="icon" size="icon"
@@ -2,7 +2,7 @@ import { Music } from '@/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { X, Play, Pause, SkipBack, SkipForward, Heart, ListPlus, Share2, MessageSquareText, Repeat, Repeat1, Shuffle } from 'lucide-react' import { X, Play, Pause, SkipBack, SkipForward, Heart, ListPlus, Share2, MessageSquareText, Repeat, Repeat1, Shuffle } from 'lucide-react'
import { formatDuration } from '@/lib/utils' import { formatDuration, getMusicThumbnail } from '@/lib/utils'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { musicApi } from '@/api/client' import { musicApi } from '@/api/client'
import React from 'react' import React from 'react'
@@ -113,17 +113,21 @@ export default function FullScreenPlayer({
<div className="flex-1 flex flex-col items-center justify-center p-8 overflow-y-auto"> <div className="flex-1 flex flex-col items-center justify-center p-8 overflow-y-auto">
{/* Large Album Art */} {/* Large Album Art */}
<div className={`w-full max-w-lg ${showLyrics ? 'mb-4 md:mb-8' : 'mb-8'}`}> <div className={`w-full max-w-lg ${showLyrics ? 'mb-4 md:mb-8' : 'mb-8'}`}>
{currentMusic.thumbnail ? ( {getMusicThumbnail(currentMusic) ? (
<img <img
src={currentMusic.thumbnail.startsWith('http') ? currentMusic.thumbnail : `/music/${currentMusic.thumbnail}`} src={getMusicThumbnail(currentMusic)!}
alt={currentMusic.title} alt={currentMusic.title}
className={`w-full aspect-square rounded-2xl object-cover shadow-2xl ${showLyrics ? 'max-w-xs md:max-w-lg mx-auto' : ''}`} className={`w-full aspect-square rounded-2xl object-cover shadow-2xl ${showLyrics ? 'max-w-xs md:max-w-lg mx-auto' : ''}`}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.nextElementSibling?.classList.remove('hidden')
}}
/> />
) : ( ) : null}
<div className={`w-full aspect-square rounded-2xl bg-secondary flex items-center justify-center shadow-2xl ${showLyrics ? 'max-w-xs md:max-w-lg mx-auto' : ''}`}> <div className={`w-full aspect-square rounded-2xl bg-secondary flex items-center justify-center shadow-2xl ${showLyrics ? 'max-w-xs md:max-w-lg mx-auto' : ''} ${getMusicThumbnail(currentMusic) ? 'hidden' : ''}`}>
<Play className={showLyrics ? 'h-24 w-24 md:h-32 md:w-32 text-muted-foreground' : 'h-32 w-32 text-muted-foreground'} /> <Play className={showLyrics ? 'h-24 w-24 md:h-32 md:w-32 text-muted-foreground' : 'h-32 w-32 text-muted-foreground'} />
</div> </div>
)}
</div> </div>
{/* Song Info */} {/* Song Info */}
+22 -17
View File
@@ -5,7 +5,7 @@ import { Music } from '@/types'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Heart, ListPlus, Repeat, Repeat1, Shuffle, Share2, Maximize2, MessageSquareText, Edit } from 'lucide-react' import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Heart, ListPlus, Repeat, Repeat1, Shuffle, Share2, Maximize2, MessageSquareText, Edit } from 'lucide-react'
import { formatDuration } from '@/lib/utils' import { formatDuration, getMusicThumbnail } from '@/lib/utils'
import { playlistApi, musicApi } from '@/api/client' import { playlistApi, musicApi } from '@/api/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import PlaylistSelector from './PlaylistSelector' import PlaylistSelector from './PlaylistSelector'
@@ -117,12 +117,13 @@ export default function Player({
useEffect(() => { useEffect(() => {
if (!currentMusic || !('mediaSession' in navigator)) return if (!currentMusic || !('mediaSession' in navigator)) return
const getAbsoluteImageUrl = (thumbnail: string | null) => { const thumbnailUrl = getMusicThumbnail(currentMusic)
if (!thumbnail) return undefined const getAbsoluteImageUrl = (url: string | null) => {
if (thumbnail.startsWith('http')) return thumbnail if (!url) return undefined
if (url.startsWith('http')) return url
// Convert relative path to absolute URL // Convert relative path to absolute URL
const baseUrl = window.location.origin const baseUrl = window.location.origin
return `${baseUrl}/music/${thumbnail}` return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`
} }
// Set metadata for lock screen // Set metadata for lock screen
@@ -130,8 +131,8 @@ export default function Player({
title: currentMusic.title, title: currentMusic.title,
artist: currentMusic.artist || 'Unknown Artist', artist: currentMusic.artist || 'Unknown Artist',
album: currentMusic.album || '', album: currentMusic.album || '',
artwork: currentMusic.thumbnail ? [ artwork: thumbnailUrl ? [
{ src: getAbsoluteImageUrl(currentMusic.thumbnail) || '', sizes: '512x512', type: 'image/jpeg' }, { src: getAbsoluteImageUrl(thumbnailUrl) || '', sizes: '512x512', type: 'image/jpeg' },
] : undefined, ] : undefined,
}) })
@@ -265,21 +266,25 @@ export default function Player({
<div className="flex items-center justify-between gap-2 md:gap-4"> <div className="flex items-center justify-between gap-2 md:gap-4">
{/* Album art and music info */} {/* Album art and music info */}
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0"> <div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
{currentMusic.thumbnail ? ( {getMusicThumbnail(currentMusic) ? (
<img <img
src={currentMusic.thumbnail.startsWith('http') ? currentMusic.thumbnail : `/music/${currentMusic.thumbnail}`} src={getMusicThumbnail(currentMusic)!}
alt={currentMusic.title} alt={currentMusic.title}
className="w-12 h-12 md:w-14 md:h-14 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity" className="w-12 h-12 md:w-14 md:h-14 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowFullScreen(true)} onClick={() => setShowFullScreen(true)}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.nextElementSibling?.classList.remove('hidden')
}}
/> />
) : ( ) : null}
<div <div
className="w-12 h-12 md:w-14 md:h-14 rounded bg-secondary flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity" className={`w-12 h-12 md:w-14 md:h-14 rounded bg-secondary flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${getMusicThumbnail(currentMusic) ? 'hidden' : ''}`}
onClick={() => setShowFullScreen(true)} onClick={() => setShowFullScreen(true)}
> >
<Play className="h-6 w-6 md:h-7 md:w-7 text-muted-foreground" /> <Play className="h-6 w-6 md:h-7 md:w-7 text-muted-foreground" />
</div> </div>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 <h3
className="font-semibold truncate cursor-pointer hover:underline" className="font-semibold truncate cursor-pointer hover:underline"
+24
View File
@@ -47,3 +47,27 @@ export function formatArtist(artist: string | null | undefined, maxLength?: numb
// Last resort // Last resort
return `+${artists.length}` return `+${artists.length}`
} }
/**
* Get the best available thumbnail for a music item
* Priority: Music thumbnail → Artist image → null (for default icon)
*/
export function getMusicThumbnail(music: { thumbnail?: string | null, artist?: string | null }): string | null {
// If music has a thumbnail, use it
if (music.thumbnail) {
return music.thumbnail.startsWith('http')
? music.thumbnail
: `/music/${music.thumbnail}`
}
// If no thumbnail but has artist, try artist image
if (music.artist && music.artist !== 'Unknown') {
// Get first artist if multiple
const firstArtist = music.artist.split(/[,/]/)[0].trim()
// Return artist image path (backend will handle the lookup)
return `/api/artists/${encodeURIComponent(firstArtist)}/image`
}
// No thumbnail or artist available
return null
}