2026-03-02 18:16:04 +00:00
|
|
|
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'
|
2026-03-02 21:29:50 +00:00
|
|
|
const podcastCoverCache = new Map<string, string>()
|
|
|
|
|
const SESSION_PODCAST_KEY = 'aiui-podcast-cover-cache'
|
2026-03-02 18:16:04 +00:00
|
|
|
|
|
|
|
|
function musicCacheKey(artist: string, title: string): string {
|
|
|
|
|
return `${artist.toLowerCase().trim()}|${title.toLowerCase().trim()}`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 21:29:50 +00:00
|
|
|
function podcastCacheKey(title: string, host?: string): string {
|
|
|
|
|
return `${title.toLowerCase().trim()}|${(host ?? '').toLowerCase().trim()}`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 18:16:04 +00:00
|
|
|
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()
|
|
|
|
|
|
2026-03-02 21:29:50 +00:00
|
|
|
function loadPodcastCache(): void {
|
|
|
|
|
try {
|
|
|
|
|
const raw = sessionStorage.getItem(SESSION_PODCAST_KEY)
|
|
|
|
|
if (raw) {
|
|
|
|
|
const parsed = JSON.parse(raw) as Record<string, string>
|
|
|
|
|
Object.entries(parsed).forEach(([k, v]) => podcastCoverCache.set(k, v))
|
|
|
|
|
}
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function savePodcastCache(): void {
|
|
|
|
|
try {
|
|
|
|
|
const entries = [...podcastCoverCache.entries()].slice(-200)
|
|
|
|
|
sessionStorage.setItem(SESSION_PODCAST_KEY, JSON.stringify(Object.fromEntries(entries)))
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadPodcastCache()
|
|
|
|
|
|
|
|
|
|
const MICROPHONE_PATH = 'M45.04,95.45v24.11c0,1.83-1.49,3.32-3.32,3.32c-1.83,0-3.32-1.49-3.32-3.32V95.45c-10.16-0.81-19.32-5.3-26.14-12.12C4.69,75.77,0,65.34,0,53.87c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,9.64,3.95,18.41,10.31,24.77c6.36,6.36,15.13,10.31,24.77,10.31h0c9.64,0,18.41-3.95,24.77-10.31c6.36-6.36,10.31-15.13,10.31-24.77c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,11.48-4.69,21.91-12.25,29.47C64.36,90.16,55.2,94.64,45.04,95.45z M41.94,0c6.38,0,12.18,2.61,16.38,6.81c4.2,4.2,6.81,10,6.81,16.38v30c0,6.38-2.61,12.18-6.81,16.38c-4.2,4.2-10,6.81-16.38,6.81s-12.18-2.61-16.38-6.81c-4.2-4.2-6.81-10-6.81-16.38v-30c0-6.38,2.61-12.18,6.81-16.38C29.76,2.61,35.56,0,41.94,0z M53.62,11.51c-3-3-7.14-4.86-11.68-4.86c-4.55,0-8.68,1.86-11.68,4.86c-3,3-4.86,7.14-4.86,11.68v30c0,4.55,1.86,8.68,4.86,11.68c3,3,7.14,4.86,11.68,4.86c4.55,0,8.68-1.86,11.68-4.86c3-3,4.86-7.14,4.86-11.68v-30C58.49,18.64,56.62,14.51,53.62,11.51z'
|
|
|
|
|
|
|
|
|
|
export function generatePodcastCoverFallback(_title: string, _host?: string): string {
|
|
|
|
|
const hue = 220
|
2026-03-02 19:57:44 +00:00
|
|
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
2026-03-02 21:29:50 +00:00
|
|
|
<rect width="200" height="200" fill="hsl(${hue}, 25%, 12%)"/>
|
|
|
|
|
<rect x="2" y="2" width="196" height="196" rx="8" fill="none" stroke="hsl(${hue}, 30%, 22%)" stroke-width="2"/>
|
|
|
|
|
<g fill="hsl(${hue}, 40%, 55%)" transform="translate(38 8) scale(1.5)">
|
|
|
|
|
<path d="${MICROPHONE_PATH}"/>
|
|
|
|
|
</g>
|
2026-03-02 19:57:44 +00:00
|
|
|
</svg>`
|
|
|
|
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 18:16:04 +00:00
|
|
|
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)}`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:48:17 +00:00
|
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 18:16:04 +00:00
|
|
|
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> {
|
2026-03-02 16:48:17 +00:00
|
|
|
const img = e.target as HTMLImageElement
|
2026-03-02 18:16:04 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 21:29:50 +00:00
|
|
|
/** Fetch podcast artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
|
|
|
|
|
export async function fetchPodcastCover(
|
|
|
|
|
title: string,
|
|
|
|
|
host?: string,
|
|
|
|
|
): Promise<string | null> {
|
|
|
|
|
const key = podcastCacheKey(title, host)
|
|
|
|
|
const cached = podcastCoverCache.get(key)
|
|
|
|
|
if (cached) return cached
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const term = host ? `${title} ${host}` : title
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`https://itunes.apple.com/search?term=${encodeURIComponent(term.trim())}&media=podcast&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')
|
|
|
|
|
podcastCoverCache.set(key, hiRes)
|
|
|
|
|
savePodcastCache()
|
|
|
|
|
return hiRes
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 18:16:04 +00:00
|
|
|
/** 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
|
2026-03-02 16:48:17 +00:00
|
|
|
}
|
|
|
|
|
}
|