import type { RichContent } from "../../shared/dump.ts"; import type { RichContentProvider } from "../remote/providers/provider.ts"; import { youtubeProvider } from "../remote/providers/youtube.ts"; import { bandcampProvider } from "../remote/providers/bandcamp.ts"; import { soundcloudProvider } from "../remote/providers/soundcloud.ts"; import { selfProvider } from "../remote/providers/self.ts"; import { genericProvider } from "../remote/providers/generic.ts"; /** * Register providers in priority order. The first match wins. * `selfProvider` resolves gerbeur URLs directly from the DB (no HTTP round-trip). * `genericProvider` must stay last — it always matches. */ const providers: RichContentProvider[] = [ youtubeProvider, bandcampProvider, soundcloudProvider, selfProvider, genericProvider, ]; export interface FetchRichContentResult { /** False when the page couldn't be reached at all, as opposed to reached and * found to carry no metadata. Callers that already hold good metadata must * not overwrite it with a failure stub. */ ok: boolean; content?: RichContent; } /** * Fetch metadata for `url`, reporting whether the fetch itself succeeded. * * On failure it still yields a minimal stub so a *new* dump has something * displayable, but `ok: false` lets callers with existing metadata keep it. */ // Metadata fetched in the last few minutes, by URL. The create form previews a // link and then posts it; without this the page was fetched twice, and the // second fetch was the slow part of posting. Only successes are kept, and a // metadata refresh (`fresh`) always goes to the page. const RECENT_TTL_MS = 5 * 60 * 1000; const RECENT_MAX = 200; const recent = new Map< string, { at: number; result: Promise } >(); /** Forget every cached result (tests). */ export function clearRichContentCache(): void { recent.clear(); } export function tryFetchRichContent( url: string, { fresh = false }: { fresh?: boolean } = {}, ): Promise { const hit = recent.get(url); if (!fresh && hit && Date.now() - hit.at < RECENT_TTL_MS) return hit.result; recent.delete(url); // Stored while in flight too, so a preview and a post racing share a fetch. const result = fetchRichContentUncached(url); if (recent.size >= RECENT_MAX) { const oldest = recent.keys().next().value; if (oldest !== undefined) recent.delete(oldest); } const entry = { at: Date.now(), result }; recent.set(url, entry); result.then(({ ok }) => { if (!ok && recent.get(url) === entry) recent.delete(url); }); return result; } async function fetchRichContentUncached( url: string, ): Promise { try { const provider = providers.find((p) => p.matches(url))!; return { ok: true, content: await provider.fetch(url) }; } catch (err) { console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err); try { return { ok: false, content: { type: "generic", url, siteName: new URL(url).hostname.replace(/^www\./, ""), }, }; } catch { return { ok: false }; } } } /** Metadata for `url`, or a minimal stub when it can't be fetched. */ export async function fetchRichContent( url: string, ): Promise { return (await tryFetchRichContent(url)).content; }