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])
async def get_artist_songs(
artist_name: str,
+24 -14
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Play, AlertCircle, Info, Search, LayoutList, LayoutGrid, ArrowUpDown, Loader2 } from 'lucide-react'
import MusicDetailModal from './music/MusicDetailModal'
import { getMusicThumbnail } from '@/lib/utils'
import {
Select,
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"
onClick={() => music.file_exists && onPlayMusic(music, filteredMusic)}
>
{music.thumbnail ? (
{getMusicThumbnail(music) ? (
<img
src={music.thumbnail.startsWith('http') ? music.thumbnail : `/music/${music.thumbnail}`}
src={getMusicThumbnail(music)!}
alt={music.title}
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')
}}
/>
) : (
<div className="w-12 h-12 rounded bg-secondary flex items-center justify-center flex-shrink-0">
<Play className="h-6 w-6 text-muted-foreground" />
</div>
)}
) : null}
<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" />
</div>
<div className="flex-1 min-w-0">
<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"
>
<div className="relative aspect-square">
{music.thumbnail ? (
{getMusicThumbnail(music) ? (
<img
src={music.thumbnail.startsWith('http') ? music.thumbnail : `/music/${music.thumbnail}`}
src={getMusicThumbnail(music)!}
alt={music.title}
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')
}}
/>
) : (
<div className="w-full h-full rounded bg-secondary flex items-center justify-center">
<Play className="h-12 w-12 text-muted-foreground" />
</div>
)}
) : null}
<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" />
</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">
<Button
size="icon"
@@ -2,7 +2,7 @@ import { Music } from '@/types'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
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 { musicApi } from '@/api/client'
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">
{/* Large Album Art */}
<div className={`w-full max-w-lg ${showLyrics ? 'mb-4 md:mb-8' : 'mb-8'}`}>
{currentMusic.thumbnail ? (
{getMusicThumbnail(currentMusic) ? (
<img
src={currentMusic.thumbnail.startsWith('http') ? currentMusic.thumbnail : `/music/${currentMusic.thumbnail}`}
src={getMusicThumbnail(currentMusic)!}
alt={currentMusic.title}
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')
}}
/>
) : (
<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' : ''}`}>
<Play className={showLyrics ? 'h-24 w-24 md:h-32 md:w-32 text-muted-foreground' : 'h-32 w-32 text-muted-foreground'} />
</div>
)}
) : 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' : ''} ${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'} />
</div>
</div>
{/* Song Info */}
+22 -17
View File
@@ -5,7 +5,7 @@ import { Music } from '@/types'
import { Slider } from '@/components/ui/slider'
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 { formatDuration } from '@/lib/utils'
import { formatDuration, getMusicThumbnail } from '@/lib/utils'
import { playlistApi, musicApi } from '@/api/client'
import { toast } from 'sonner'
import PlaylistSelector from './PlaylistSelector'
@@ -117,12 +117,13 @@ export default function Player({
useEffect(() => {
if (!currentMusic || !('mediaSession' in navigator)) return
const getAbsoluteImageUrl = (thumbnail: string | null) => {
if (!thumbnail) return undefined
if (thumbnail.startsWith('http')) return thumbnail
const thumbnailUrl = getMusicThumbnail(currentMusic)
const getAbsoluteImageUrl = (url: string | null) => {
if (!url) return undefined
if (url.startsWith('http')) return url
// Convert relative path to absolute URL
const baseUrl = window.location.origin
return `${baseUrl}/music/${thumbnail}`
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`
}
// Set metadata for lock screen
@@ -130,8 +131,8 @@ export default function Player({
title: currentMusic.title,
artist: currentMusic.artist || 'Unknown Artist',
album: currentMusic.album || '',
artwork: currentMusic.thumbnail ? [
{ src: getAbsoluteImageUrl(currentMusic.thumbnail) || '', sizes: '512x512', type: 'image/jpeg' },
artwork: thumbnailUrl ? [
{ src: getAbsoluteImageUrl(thumbnailUrl) || '', sizes: '512x512', type: 'image/jpeg' },
] : undefined,
})
@@ -265,21 +266,25 @@ export default function Player({
<div className="flex items-center justify-between gap-2 md:gap-4">
{/* Album art and music info */}
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
{currentMusic.thumbnail ? (
{getMusicThumbnail(currentMusic) ? (
<img
src={currentMusic.thumbnail.startsWith('http') ? currentMusic.thumbnail : `/music/${currentMusic.thumbnail}`}
src={getMusicThumbnail(currentMusic)!}
alt={currentMusic.title}
className="w-12 h-12 md:w-14 md:h-14 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowFullScreen(true)}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.nextElementSibling?.classList.remove('hidden')
}}
/>
) : (
<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"
onClick={() => setShowFullScreen(true)}
>
<Play className="h-6 w-6 md:h-7 md:w-7 text-muted-foreground" />
</div>
)}
) : null}
<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 ${getMusicThumbnail(currentMusic) ? 'hidden' : ''}`}
onClick={() => setShowFullScreen(true)}
>
<Play className="h-6 w-6 md:h-7 md:w-7 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<h3
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
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
}