All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
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<FetchRichContentResult> }
|
|
>();
|
|
|
|
/** Forget every cached result (tests). */
|
|
export function clearRichContentCache(): void {
|
|
recent.clear();
|
|
}
|
|
|
|
export function tryFetchRichContent(
|
|
url: string,
|
|
{ fresh = false }: { fresh?: boolean } = {},
|
|
): Promise<FetchRichContentResult> {
|
|
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<FetchRichContentResult> {
|
|
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<RichContent | undefined> {
|
|
return (await tryFetchRichContent(url)).content;
|
|
}
|