Files
archy/packages/app/src/composables/useImageFallback.ts
T

177 lines
6.2 KiB
TypeScript
Raw Normal View History

type TmdbResult = { posterUrl: string | null; backdropUrl: string | null }
const memoryCache = new Map<string, TmdbResult>()
const SESSION_KEY = 'aiui-poster-cache'
const failedUrls = new Set<string>()
const musicCoverCache = new Map<string, string>()
const SESSION_MUSIC_KEY = 'aiui-music-cover-cache'
function musicCacheKey(artist: string, title: string): string {
return `${artist.toLowerCase().trim()}|${title.toLowerCase().trim()}`
}
function loadMusicCache(): void {
try {
const raw = sessionStorage.getItem(SESSION_MUSIC_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, string>
Object.entries(parsed).forEach(([k, v]) => musicCoverCache.set(k, v))
}
} catch { /* ignore */ }
}
function saveMusicCache(): void {
try {
const entries = [...musicCoverCache.entries()].slice(-300)
sessionStorage.setItem(SESSION_MUSIC_KEY, JSON.stringify(Object.fromEntries(entries)))
} catch { /* ignore */ }
}
loadMusicCache()
function cacheKey(t: string, y?: number): string {
return `${t.toLowerCase().trim()}|${y ?? ''}`
}
function loadSessionCache(): void {
try {
const raw = sessionStorage.getItem(SESSION_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, TmdbResult>
Object.entries(parsed).forEach(([k, v]) => memoryCache.set(k, v))
}
} catch { /* ignore */ }
}
function saveSessionCache(): void {
try {
const entries = [...memoryCache.entries()].slice(-200)
sessionStorage.setItem(SESSION_KEY, JSON.stringify(Object.fromEntries(entries)))
} catch { /* ignore */ }
}
loadSessionCache()
export function generateSongCoverFallback(title: string, artist?: string): string {
const hue = [...(title + (artist ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="hsl(${hue}, 30%, 14%)"/>
<rect x="2" y="2" width="196" height="196" rx="8" fill="none" stroke="hsl(${hue}, 40%, 25%)" stroke-width="2"/>
<path d="M100 50 v100 M70 70 q30-20 60 0 M70 130 q30 20 60 0" fill="none" stroke="hsl(${hue}, 50%, 60%)" stroke-width="4" stroke-linecap="round"/>
<text x="100" y="115" text-anchor="middle" fill="hsl(${hue}, 50%, 70%)" font-family="system-ui" font-size="14" font-weight="600">${escapeXml(title.length > 12 ? title.slice(0, 10) + '…' : title)}</text>
${artist ? `<text x="100" y="135" text-anchor="middle" fill="hsl(${hue}, 30%, 50%)" font-family="system-ui" font-size="10">${escapeXml(artist.length > 14 ? artist.slice(0, 12) + '…' : artist)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
export function generatePosterFallback(title: string, year?: number): string {
const hue = [...title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342 513">
<rect width="342" height="513" fill="hsl(${hue}, 30%, 15%)"/>
<rect x="1" y="1" width="340" height="511" rx="8" fill="none" stroke="hsl(${hue}, 40%, 25%)" stroke-width="2"/>
<text x="171" y="230" text-anchor="middle" fill="hsl(${hue}, 50%, 70%)" font-family="system-ui" font-size="20" font-weight="600">
${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)}
</text>
${year ? `<text x="171" y="260" text-anchor="middle" fill="hsl(${hue}, 30%, 50%)" font-family="system-ui" font-size="14">${year}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
export async function fetchTmdbPoster(
title: string,
year?: number,
): Promise<TmdbResult> {
const key = cacheKey(title, year)
const cached = memoryCache.get(key)
if (cached) return cached
const empty: TmdbResult = { posterUrl: null, backdropUrl: null }
try {
const params = new URLSearchParams({ q: title.trim() })
if (year && year > 0) params.set('y', String(year))
const res = await fetch(`/api/tmdb/search?${params}`)
if (!res.ok) return empty
const data = (await res.json()) as { posterUrl?: string | null; backdropUrl?: string | null }
const result: TmdbResult = {
posterUrl: data.posterUrl ?? null,
backdropUrl: data.backdropUrl ?? null,
}
if (result.posterUrl || result.backdropUrl) {
memoryCache.set(key, result)
saveSessionCache()
}
return result
} catch {
return empty
}
}
export async function handleImgError(
e: Event,
title: string,
year?: number,
): Promise<void> {
const img = e.target as HTMLImageElement
if (img.dataset.fallback === 'done') return
const originalSrc = img.src
failedUrls.add(originalSrc)
const key = cacheKey(title, year)
const cached = memoryCache.get(key)
if (cached?.posterUrl && cached.posterUrl !== originalSrc) {
img.dataset.fallback = 'tmdb'
img.src = cached.posterUrl
return
}
if (img.dataset.fallback !== 'tmdb') {
const { posterUrl } = await fetchTmdbPoster(title, year)
if (posterUrl && posterUrl !== originalSrc) {
img.dataset.fallback = 'tmdb'
img.src = posterUrl
return
}
}
img.dataset.fallback = 'done'
img.src = generatePosterFallback(title, year)
}
export function isUrlFailed(url: string | undefined): boolean {
return !!url && failedUrls.has(url)
}
/** Fetch album artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
export async function fetchMusicCover(
title: string,
artist: string,
album?: string,
): Promise<string | null> {
const key = musicCacheKey(artist, title)
const cached = musicCoverCache.get(key)
if (cached) return cached
try {
const term = `${artist} ${title}`.trim().replace(/\s+/g, '+')
const res = await fetch(
`https://itunes.apple.com/search?term=${encodeURIComponent(term)}&media=music&limit=3`,
)
if (!res.ok) return null
const data = (await res.json()) as { results?: { artworkUrl100?: string }[] }
const first = data.results?.[0]
const url = first?.artworkUrl100
if (!url) return null
const hiRes = url.replace(/100x100/g, '600x600')
musicCoverCache.set(key, hiRes)
saveMusicCache()
return hiRes
} catch {
return null
}
}