/** * Local Media Provider Runtime * * This is the runtime implementation loaded by the entrypoint. * It wraps the existing MediaRepository and storage adapter. * * Note: This provider is special because it needs access to the database * and storage adapter. The createMediaProvider function receives these * via the config object, injected by the runtime. */ import type { Kysely } from "kysely"; import { MediaRepository } from "../database/repositories/media.js"; import type { Database } from "../database/types.js"; import type { Storage } from "../index.js"; import { invalidateSiteSettingsCache } from "../settings/index.js"; import type { CreateMediaProviderFn, MediaProvider, MediaListOptions, MediaProviderItem, MediaValue, EmbedResult, EmbedOptions, } from "./types.js"; export interface LocalMediaRuntimeConfig { enabled?: boolean; // These are injected by the runtime, not from user config db?: Kysely; /** * Resolver for the live connection, preferred over `db`. The runtime * injects it so a connection-backed adapter (Postgres over Hyperdrive) * serves provider queries from the current request-scoped connection in ALS * rather than a snapshot of the per-isolate singleton. Omitted for stateless * adapters (D1, Node SQLite), where `db` is used directly. */ getDb?: () => Kysely; storage?: Storage; } /** * Create the local media provider */ export const createMediaProvider: CreateMediaProviderFn = (config) => { const { db, storage } = config; if (!db) { throw new Error("Local media provider requires database connection"); } // Resolve the connection per operation (not captured once) so a // connection-backed adapter uses the current event-scoped connection; falls // back to the injected `db` for stateless adapters. const resolveDb = config.getDb ?? (() => db); const repo = () => new MediaRepository(resolveDb()); const provider: MediaProvider = { async list(options: MediaListOptions) { const result = await repo().findMany({ cursor: options.cursor, limit: options.limit, mimeType: options.mimeType, // TODO: Add search support when capabilities.search is true }); return { items: result.items.map((item) => ({ id: item.id, filename: item.filename, mimeType: item.mimeType, size: item.size ?? undefined, width: item.width ?? undefined, height: item.height ?? undefined, blurhash: item.blurhash ?? undefined, dominantColor: item.dominantColor ?? undefined, alt: item.alt ?? undefined, previewUrl: `/_emdash/api/media/file/${item.storageKey}`, meta: { storageKey: item.storageKey, caption: item.caption, blurhash: item.blurhash, dominantColor: item.dominantColor, }, })), nextCursor: result.nextCursor, }; }, async get(id: string) { const item = await repo().findById(id); if (!item) return null; return { id: item.id, filename: item.filename, mimeType: item.mimeType, size: item.size ?? undefined, width: item.width ?? undefined, height: item.height ?? undefined, blurhash: item.blurhash ?? undefined, dominantColor: item.dominantColor ?? undefined, alt: item.alt ?? undefined, previewUrl: `/_emdash/api/media/file/${item.storageKey}`, meta: { storageKey: item.storageKey, caption: item.caption, blurhash: item.blurhash, dominantColor: item.dominantColor, }, }; }, async upload(_input) { if (!storage) { throw new Error("Storage not configured for local media provider"); } // This is handled by the existing media upload endpoint // The provider interface is used by external providers // For local, we delegate to the existing system throw new Error("Local upload should use /_emdash/api/media endpoint"); }, async delete(id: string) { const repoInstance = repo(); const item = await repoInstance.findById(id); if (!item) return; // Delete from storage if available if (storage) { try { await storage.delete(item.storageKey); } catch { // Ignore storage deletion errors } } await repoInstance.delete(id); // If this row was referenced by `logo`, `favicon`, or // `seo.defaultOgImage`, the worker-scoped settings cache now // holds a stale URL. The provider routes (and any future caller) // bypass `handleMediaDelete`, so we invalidate here too. invalidateSiteSettingsCache(); }, getEmbed(value: MediaValue, _options?: EmbedOptions): EmbedResult { const storageKey = typeof value.meta?.storageKey === "string" ? value.meta.storageKey : value.id; const src = `/_emdash/api/media/file/${storageKey}`; const mimeType = value.mimeType || ""; // Prefer the first-class fields; fall back to `meta` for legacy snapshots // stored before LQIP was promoted off the provider-specific `meta` bag. const blurhash = value.blurhash ?? (typeof value.meta?.blurhash === "string" ? value.meta.blurhash : undefined); const dominantColor = value.dominantColor ?? (typeof value.meta?.dominantColor === "string" ? value.meta.dominantColor : undefined); // Determine embed type based on MIME type if (mimeType.startsWith("image/")) { return { type: "image", src, width: value.width, height: value.height, blurhash, dominantColor, alt: value.alt, }; } if (mimeType.startsWith("video/")) { return { type: "video", src, width: value.width, height: value.height, controls: true, preload: "metadata", }; } if (mimeType.startsWith("audio/")) { return { type: "audio", src, controls: true, preload: "metadata", }; } // Fallback: treat as image (for unknown types) return { type: "image", src, width: value.width, height: value.height, blurhash, dominantColor, alt: value.alt, }; }, getThumbnailUrl(id: string, _mimeType?: string) { // For local media, return the file URL return `/_emdash/api/media/file/${id}`; }, }; return provider; }; /** * Helper to convert a MediaRepository item to MediaProviderItem */ export function repoItemToProviderItem(item: { id: string; filename: string; mimeType: string; size: number | null; width: number | null; height: number | null; alt: string | null; caption: string | null; storageKey: string; blurhash: string | null; dominantColor: string | null; }): MediaProviderItem { return { id: item.id, filename: item.filename, mimeType: item.mimeType, size: item.size ?? undefined, width: item.width ?? undefined, height: item.height ?? undefined, blurhash: item.blurhash ?? undefined, dominantColor: item.dominantColor ?? undefined, alt: item.alt ?? undefined, previewUrl: `/_emdash/api/media/file/${item.storageKey}`, meta: { storageKey: item.storageKey, caption: item.caption, blurhash: item.blurhash, dominantColor: item.dominantColor, }, }; }