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

537 lines
23 KiB
TypeScript

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'
const podcastCoverCache = new Map<string, string>()
const SESSION_PODCAST_KEY = 'aiui-podcast-cover-cache'
function musicCacheKey(artist: string, title: string): string {
return `${artist.toLowerCase().trim()}|${title.toLowerCase().trim()}`
}
function podcastCacheKey(title: string, host?: string): string {
return `${title.toLowerCase().trim()}|${(host ?? '').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()
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 = [...title].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%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 35%, 38%)" stroke-width="1"/>
<text x="100" y="78" text-anchor="middle" fill="hsl(${hue}, 35%, 50%)" font-family="system-ui,sans-serif" font-size="10" font-weight="500" letter-spacing="3">PODCAST</text>
<g fill="hsl(${hue}, 40%, 65%)" transform="translate(58 82) scale(1.0)">
<path d="${MICROPHONE_PATH}"/>
</g>
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
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%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 40%, 38%)" stroke-width="1"/>
<text x="100" y="80" text-anchor="middle" fill="hsl(${hue}, 35%, 50%)" font-family="system-ui,sans-serif" font-size="10" font-weight="500" letter-spacing="3">MUSIC</text>
<circle cx="100" cy="120" r="28" fill="none" stroke="hsl(${hue}, 45%, 52%)" stroke-width="1.5"/>
<circle cx="100" cy="120" r="8" fill="hsl(${hue}, 45%, 52%)"/>
<text x="100" y="170" text-anchor="middle" fill="hsl(${hue}, 50%, 80%)" font-family="system-ui,sans-serif" font-size="11" font-weight="600">${escapeXml(title.length > 18 ? title.slice(0, 16) + '…' : title)}</text>
${artist ? `<text x="100" y="185" text-anchor="middle" fill="hsl(${hue}, 30%, 65%)" font-family="system-ui,sans-serif" font-size="9">${escapeXml(artist.length > 20 ? artist.slice(0, 18) + '…' : artist)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
/** News article fallback — newspaper style with category badge */
export function generateNewsFallback(title: string, source?: string): string {
const hue = [...(title + (source ?? ''))].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}, 22%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 28%, 38%)" stroke-width="1"/>
<text x="100" y="72" text-anchor="middle" fill="hsl(${hue}, 25%, 50%)" font-family="Georgia,'Times New Roman',serif" font-size="10" font-weight="400" letter-spacing="3">NEWS</text>
<line x1="50" y1="82" x2="150" y2="82" stroke="hsl(${hue}, 28%, 38%)" stroke-width="0.8"/>
<line x1="40" y1="100" x2="160" y2="100" stroke="hsl(${hue}, 22%, 40%)" stroke-width="3"/>
<line x1="50" y1="112" x2="150" y2="112" stroke="hsl(${hue}, 22%, 37%)" stroke-width="2"/>
<line x1="55" y1="122" x2="145" y2="122" stroke="hsl(${hue}, 22%, 35%)" stroke-width="1.5"/>
<line x1="60" y1="132" x2="140" y2="132" stroke="hsl(${hue}, 22%, 34%)" stroke-width="1"/>
${source ? `<text x="100" y="165" text-anchor="middle" fill="hsl(${hue}, 30%, 65%)" font-family="system-ui,sans-serif" font-size="9" font-weight="400">${escapeXml(source.length > 22 ? source.slice(0, 20) + '…' : source)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
/** Image fallback — picture frame style */
export function generateImageFallback(title: string): 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 200 200">
<rect width="200" height="200" fill="hsl(${hue}, 20%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 25%, 38%)" stroke-width="1"/>
<text x="100" y="72" text-anchor="middle" fill="hsl(${hue}, 22%, 48%)" font-family="system-ui,sans-serif" font-size="10" font-weight="500" letter-spacing="3">IMAGE</text>
<rect x="60" y="85" width="80" height="60" rx="4" fill="none" stroke="hsl(${hue}, 30%, 48%)" stroke-width="1.5"/>
<circle cx="80" cy="102" r="6" fill="hsl(${hue}, 35%, 50%)"/>
<path d="M60 135 L85 115 L105 128 L120 118 L140 135 Z" fill="hsl(${hue}, 30%, 40%)"/>
<text x="100" y="170" text-anchor="middle" fill="hsl(${hue}, 35%, 68%)" font-family="system-ui,sans-serif" font-size="10" font-weight="500">${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)}</text>
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
/** Film poster fallback — cinematic sans-serif */
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}, 25%, 28%)"/>
<rect x="3" y="3" width="336" height="507" rx="6" fill="none" stroke="hsl(${hue}, 30%, 38%)" stroke-width="1"/>
<text x="171" y="210" text-anchor="middle" fill="hsl(${hue}, 25%, 48%)" font-family="'Helvetica Neue',Helvetica,Arial,sans-serif" font-size="13" font-weight="400" letter-spacing="5">FILM</text>
<line x1="121" y1="225" x2="221" y2="225" stroke="hsl(${hue}, 30%, 40%)" stroke-width="1"/>
<text x="171" y="265" text-anchor="middle" fill="hsl(${hue}, 45%, 78%)" font-family="'Helvetica Neue',Helvetica,Arial,sans-serif" font-size="18" font-weight="700">
${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)}
</text>
${year ? `<text x="171" y="295" text-anchor="middle" fill="hsl(${hue}, 30%, 60%)" font-family="'Helvetica Neue',Helvetica,Arial,sans-serif" font-size="14" font-weight="300">${year}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
/** TV Series poster fallback — modern grotesque */
export function generateTVSeriesFallback(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}, 22%, 28%)"/>
<rect x="3" y="3" width="336" height="507" rx="6" fill="none" stroke="hsl(${hue}, 28%, 38%)" stroke-width="1"/>
<text x="171" y="210" text-anchor="middle" fill="hsl(${hue}, 22%, 48%)" font-family="'SF Pro Display',system-ui,sans-serif" font-size="13" font-weight="400" letter-spacing="5">SERIES</text>
<line x1="111" y1="225" x2="231" y2="225" stroke="hsl(${hue}, 28%, 40%)" stroke-width="1"/>
<text x="171" y="265" text-anchor="middle" fill="hsl(${hue}, 40%, 78%)" font-family="'SF Pro Display',system-ui,sans-serif" font-size="18" font-weight="700">
${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)}
</text>
${year ? `<text x="171" y="295" text-anchor="middle" fill="hsl(${hue}, 28%, 60%)" font-family="'SF Pro Display',system-ui,sans-serif" font-size="14" font-weight="300">${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;')
}
async function fetchTmdbGeneric(
endpoint: string,
title: string,
year?: number,
): Promise<TmdbResult> {
const key = `${endpoint}:${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/${endpoint}?${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 fetchTmdbPoster(
title: string,
year?: number,
): Promise<TmdbResult> {
return fetchTmdbGeneric('search', title, year)
}
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
}
}
if (img.dataset.fallback !== 'wiki') {
const wiki = await fetchWikipediaImage(title, 'film')
if (wiki) {
img.dataset.fallback = 'wiki'
img.src = wiki
return
}
}
img.dataset.fallback = 'done'
img.src = generatePosterFallback(title, year)
}
export function isUrlFailed(url: string | undefined): boolean {
return !!url && failedUrls.has(url)
}
export async function fetchTmdbTVPoster(
title: string,
year?: number,
): Promise<TmdbResult> {
return fetchTmdbGeneric('search-tv', title, year)
}
/** 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
}
}
/** Fetch album artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
const bookCoverCache = new Map<string, string>()
const SESSION_BOOK_KEY = 'aiui-book-cover-cache'
function bookCacheKey(title: string, author: string): string {
return `${title.toLowerCase().trim()}|${author.toLowerCase().trim()}`
}
function loadBookCache(): void {
try {
const raw = sessionStorage.getItem(SESSION_BOOK_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, string>
Object.entries(parsed).forEach(([k, v]) => bookCoverCache.set(k, v))
}
} catch { /* ignore */ }
}
function saveBookCache(): void {
try {
const entries = [...bookCoverCache.entries()].slice(-200)
sessionStorage.setItem(SESSION_BOOK_KEY, JSON.stringify(Object.fromEntries(entries)))
} catch { /* ignore */ }
}
loadBookCache()
/** Book cover fallback — elegant serif with spine detail */
export function generateBookCoverFallback(title: string, author?: string): string {
const hue = [...(title + (author ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 420">
<rect width="280" height="420" fill="hsl(${hue}, 20%, 28%)"/>
<rect x="3" y="3" width="274" height="414" rx="3" fill="none" stroke="hsl(${hue}, 25%, 38%)" stroke-width="1"/>
<line x1="22" y1="3" x2="22" y2="417" stroke="hsl(${hue}, 25%, 36%)" stroke-width="1.5"/>
<text x="155" y="170" text-anchor="middle" fill="hsl(${hue}, 18%, 48%)" font-family="Georgia,'Times New Roman',serif" font-size="11" font-weight="400" letter-spacing="4">BOOK</text>
<line x1="95" y1="182" x2="215" y2="182" stroke="hsl(${hue}, 22%, 40%)" stroke-width="0.8"/>
<text x="155" y="215" text-anchor="middle" fill="hsl(${hue}, 35%, 78%)" font-family="Georgia,'Times New Roman',serif" font-size="17" font-weight="700" font-style="italic">
${escapeXml(title.length > 22 ? title.slice(0, 20) + '…' : title)}
</text>
${author ? `<text x="155" y="248" text-anchor="middle" fill="hsl(${hue}, 22%, 65%)" font-family="Georgia,'Times New Roman',serif" font-size="11" font-weight="400">${escapeXml(author.length > 26 ? author.slice(0, 24) + '…' : author)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
/** Fetch book cover from Open Library Covers API (free, no key). */
export async function fetchBookCover(
title: string,
author: string,
): Promise<string | null> {
const key = bookCacheKey(title, author)
const cached = bookCoverCache.get(key)
if (cached) return cached
try {
const q = `${title} ${author}`.trim()
const res = await fetch(
`https://openlibrary.org/search.json?q=${encodeURIComponent(q)}&limit=1&fields=cover_i`,
)
if (!res.ok) return null
const data = (await res.json()) as { docs?: { cover_i?: number }[] }
const coverId = data.docs?.[0]?.cover_i
if (!coverId) return null
const url = `https://covers.openlibrary.org/b/id/${coverId}-L.jpg`
bookCoverCache.set(key, url)
saveBookCache()
return url
} catch {
return null
}
}
/** Place/restaurant fallback — map pin with cuisine hint */
export function generatePlaceFallback(name: string, cuisine?: string): string {
const hue = [...(name + (cuisine ?? ''))].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}, 20%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 25%, 38%)" stroke-width="1"/>
<text x="100" y="72" text-anchor="middle" fill="hsl(${hue}, 20%, 48%)" font-family="system-ui,sans-serif" font-size="9" font-weight="500" letter-spacing="3">PLACE</text>
<g transform="translate(100 120) scale(1.8)" fill="none" stroke="hsl(${hue}, 35%, 65%)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M0-14C-7.7-14-14-7.7-14 0c0 10.5 14 22 14 22s14-11.5 14-22c0-7.7-6.3-14-14-14z"/>
<circle cx="0" cy="0" r="5"/>
</g>
${cuisine ? `<text x="100" y="170" text-anchor="middle" fill="hsl(${hue}, 25%, 65%)" font-family="system-ui,sans-serif" font-size="9" font-weight="400">${escapeXml(cuisine.length > 22 ? cuisine.slice(0, 20) + '…' : cuisine)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
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
}
}
// ---------------------------------------------------------------------------
// Wikipedia image source (free, no key)
// ---------------------------------------------------------------------------
const wikiImageCache = new Map<string, string | null>()
/** Fetch an image from Wikipedia REST API. Free, no API key needed. */
export async function fetchWikipediaImage(
title: string,
disambiguator?: string,
): Promise<string | null> {
const key = `${title.toLowerCase().trim()}|${(disambiguator ?? '').toLowerCase()}`
if (wikiImageCache.has(key)) return wikiImageCache.get(key) ?? null
const tryTitle = async (t: string): Promise<string | null> => {
try {
const encoded = encodeURIComponent(t.trim().replace(/\s+/g, '_'))
const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encoded}`)
if (!res.ok) return null
const data = (await res.json()) as {
thumbnail?: { source?: string }
originalimage?: { source?: string }
}
return data.originalimage?.source ?? data.thumbnail?.source ?? null
} catch {
return null
}
}
// Try exact title first
let url = await tryTitle(title)
// Try with disambiguator suffix if no result
if (!url && disambiguator) {
url = await tryTitle(`${title} (${disambiguator})`)
}
wikiImageCache.set(key, url)
return url
}
// ---------------------------------------------------------------------------
// Google Books image source (free, no key)
// ---------------------------------------------------------------------------
/** Fetch book cover from Google Books API. Free, no API key needed. */
export async function fetchGoogleBooksImage(
title: string,
author?: string,
): Promise<string | null> {
const key = bookCacheKey(title, author ?? '')
const gKey = `gbooks:${key}`
if (wikiImageCache.has(gKey)) return wikiImageCache.get(gKey) ?? null
try {
const q = author ? `intitle:${title}+inauthor:${author}` : `intitle:${title}`
const res = await fetch(
`https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(q)}&maxResults=1`,
)
if (!res.ok) return null
const data = (await res.json()) as {
items?: { volumeInfo?: { imageLinks?: { thumbnail?: string; smallThumbnail?: string } } }[]
}
const links = data.items?.[0]?.volumeInfo?.imageLinks
let url = links?.thumbnail ?? links?.smallThumbnail ?? null
// Google Books returns http URLs and small sizes — upgrade
if (url) {
url = url.replace(/^http:/, 'https:').replace(/&edge=curl/g, '')
// Request larger zoom
if (!url.includes('zoom=')) url += '&zoom=2'
}
wikiImageCache.set(gKey, url)
return url
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Chained fetchers — try multiple sources in order
// ---------------------------------------------------------------------------
/** Film image: TMDB → Wikipedia */
export async function fetchFilmImage(
title: string,
year?: number,
): Promise<{ posterUrl: string | null; backdropUrl: string | null }> {
// Try TMDB first
const tmdb = await fetchTmdbPoster(title, year)
if (tmdb.posterUrl || tmdb.backdropUrl) return tmdb
// Fall back to Wikipedia
const wiki = await fetchWikipediaImage(title, 'film')
if (wiki) return { posterUrl: wiki, backdropUrl: null }
return { posterUrl: null, backdropUrl: null }
}
/** TV series image: TMDB → Wikipedia */
export async function fetchTVImage(
title: string,
year?: number,
): Promise<{ posterUrl: string | null; backdropUrl: string | null }> {
const tmdb = await fetchTmdbTVPoster(title, year)
if (tmdb.posterUrl || tmdb.backdropUrl) return tmdb
const wiki = await fetchWikipediaImage(title, 'TV series')
if (wiki) return { posterUrl: wiki, backdropUrl: null }
return { posterUrl: null, backdropUrl: null }
}
/** Book image: Open Library → Google Books → Wikipedia */
export async function fetchBookImage(
title: string,
author?: string,
): Promise<string | null> {
// Try Open Library first
const ol = await fetchBookCover(title, author ?? '')
if (ol) return ol
// Try Google Books
const gb = await fetchGoogleBooksImage(title, author)
if (gb) return gb
// Try Wikipedia
const wiki = await fetchWikipediaImage(title, 'novel')
return wiki
}