type TmdbResult = { posterUrl: string | null; backdropUrl: string | null } const memoryCache = new Map() const SESSION_KEY = 'aiui-poster-cache' const failedUrls = new Set() const musicCoverCache = new Map() const SESSION_MUSIC_KEY = 'aiui-music-cover-cache' const podcastCoverCache = new Map() 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 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 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 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 = ` PODCAST ` 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 = ` MUSIC ${escapeXml(title.length > 18 ? title.slice(0, 16) + '…' : title)} ${artist ? `${escapeXml(artist.length > 20 ? artist.slice(0, 18) + '…' : artist)}` : ''} ` 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 = ` FILM ${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)} ${year ? `${year}` : ''} ` 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 = ` SERIES ${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)} ${year ? `${year}` : ''} ` return `data:image/svg+xml,${encodeURIComponent(svg)}` } function escapeXml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } async function fetchTmdbGeneric( endpoint: string, title: string, year?: number, ): Promise { 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 { return fetchTmdbGeneric('search', title, year) } export async function handleImgError( e: Event, title: string, year?: number, ): Promise { 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 { 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 { 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() 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 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 = ` BOOK ${escapeXml(title.length > 22 ? title.slice(0, 20) + '…' : title)} ${author ? `${escapeXml(author.length > 26 ? author.slice(0, 24) + '…' : author)}` : ''} ` 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 { 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 = ` PLACE ${cuisine ? `${escapeXml(cuisine.length > 22 ? cuisine.slice(0, 20) + '…' : cuisine)}` : ''} ` return `data:image/svg+xml,${encodeURIComponent(svg)}` } export async function fetchMusicCover( title: string, artist: string, album?: string, ): Promise { 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() /** Fetch an image from Wikipedia REST API. Free, no API key needed. */ export async function fetchWikipediaImage( title: string, disambiguator?: string, ): Promise { const key = `${title.toLowerCase().trim()}|${(disambiguator ?? '').toLowerCase()}` if (wikiImageCache.has(key)) return wikiImageCache.get(key) ?? null const tryTitle = async (t: string): Promise => { 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 { 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 { // 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 }