diff --git a/apps/web/app/api/assets/[id]/url/route.ts b/apps/web/app/api/assets/[id]/url/route.ts index f50afb2..d58384f 100644 --- a/apps/web/app/api/assets/[id]/url/route.ts +++ b/apps/web/app/api/assets/[id]/url/route.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { getDb } from "@tline/db"; import { presignGetObjectUrl } from "@tline/minio"; +import { pickVariantKey } from "./variant"; export const runtime = "nodejs"; @@ -9,7 +10,15 @@ const paramsSchema = z.object({ id: z.string().uuid() }); -const variantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]); +const legacyVariantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]); +const kindSchema = z.enum(["original", "thumb", "poster", "video_mp4"]); +const sizeSchema = z.coerce.number().int().positive(); +const legacyVariantMap = { + original: { kind: "original" as const }, + thumb_small: { kind: "thumb" as const, size: 256 }, + thumb_med: { kind: "thumb" as const, size: 768 }, + poster: { kind: "poster" as const, size: 256 }, +}; export async function GET( request: Request, @@ -26,14 +35,46 @@ export async function GET( const params = paramsParsed.data; const url = new URL(request.url); - const variantParsed = variantSchema.safeParse(url.searchParams.get("variant") ?? "original"); - if (!variantParsed.success) { - return Response.json( - { error: "invalid_query", issues: variantParsed.error.issues }, - { status: 400 }, - ); + const kindParam = url.searchParams.get("kind"); + const sizeParam = url.searchParams.get("size"); + const legacyVariantParam = url.searchParams.get("variant"); + + let requestedKind: z.infer = "original"; + let requestedSize: number | null = null; + let legacyVariant: z.infer | null = null; + + if (kindParam) { + const kindParsed = kindSchema.safeParse(kindParam); + if (!kindParsed.success) { + return Response.json( + { error: "invalid_query", issues: kindParsed.error.issues }, + { status: 400 }, + ); + } + requestedKind = kindParsed.data; + if (requestedKind !== "original") { + const sizeParsed = sizeSchema.safeParse(sizeParam); + if (!sizeParsed.success) { + return Response.json( + { error: "invalid_query", issues: sizeParsed.error.issues }, + { status: 400 }, + ); + } + requestedSize = sizeParsed.data; + } + } else if (legacyVariantParam) { + const legacyParsed = legacyVariantSchema.safeParse(legacyVariantParam); + if (!legacyParsed.success) { + return Response.json( + { error: "invalid_query", issues: legacyParsed.error.issues }, + { status: 400 }, + ); + } + legacyVariant = legacyParsed.data; + const mapped = legacyVariantMap[legacyVariant]; + requestedKind = mapped.kind; + requestedSize = "size" in mapped ? mapped.size : null; } - const variant = variantParsed.data; const db = getDb(); const rows = await db< @@ -52,32 +93,70 @@ export async function GET( limit 1 `; + const variants = await db< + { + kind: string; + size: number; + key: string; + mime_type: string; + width: number | null; + height: number | null; + }[] + >` + select kind, size, key, mime_type, width, height + from asset_variants + where asset_id = ${params.id} + `; + const asset = rows[0]; if (!asset) { return Response.json({ error: "not_found" }, { status: 404 }); } + const legacyKey = + legacyVariant === "thumb_small" + ? asset.thumb_small_key + : legacyVariant === "thumb_med" + ? asset.thumb_med_key + : legacyVariant === "poster" + ? asset.poster_key + : null; + const key = - variant === "original" + requestedKind === "original" ? asset.active_key - : variant === "thumb_small" - ? asset.thumb_small_key - : variant === "thumb_med" - ? asset.thumb_med_key - : asset.poster_key; + : requestedSize !== null + ? pickVariantKey( + { variants }, + { kind: requestedKind, size: requestedSize }, + ) ?? legacyKey + : null; if (!key) { return Response.json( - { error: "variant_not_available", variant }, + { error: "variant_not_available", kind: requestedKind, size: requestedSize }, { status: 404 } ); } // Hint the browser; especially helpful for Range playback. - const responseContentType = variant === "original" ? asset.mime_type : "image/jpeg"; + const matchedVariant = + requestedKind === "original" || requestedSize === null + ? null + : variants.find( + (item) => item.kind === requestedKind && item.size === requestedSize, + ) ?? null; + const responseContentType = + requestedKind === "original" + ? asset.mime_type + : matchedVariant?.mime_type ?? + (requestedKind === "video_mp4" ? "video/mp4" : "image/jpeg"); const responseContentDisposition = - variant === "original" && asset.mime_type.startsWith("video/") ? "inline" : undefined; + (requestedKind === "original" && asset.mime_type.startsWith("video/")) || + requestedKind === "video_mp4" + ? "inline" + : undefined; const signed = await presignGetObjectUrl({ bucket: asset.bucket, diff --git a/apps/web/app/api/assets/[id]/url/variant.ts b/apps/web/app/api/assets/[id]/url/variant.ts new file mode 100644 index 0000000..6e01082 --- /dev/null +++ b/apps/web/app/api/assets/[id]/url/variant.ts @@ -0,0 +1,9 @@ +export function pickVariantKey( + input: { variants: Array<{ kind: string; size: number; key: string }> }, + req: { kind: string; size: number }, +) { + const v = input.variants.find( + (x) => x.kind === req.kind && x.size === req.size, + ); + return v?.key ?? null; +} diff --git a/apps/web/src/__tests__/variant-url-404.test.ts b/apps/web/src/__tests__/variant-url-404.test.ts new file mode 100644 index 0000000..369fa08 --- /dev/null +++ b/apps/web/src/__tests__/variant-url-404.test.ts @@ -0,0 +1,9 @@ +import { test, expect } from "bun:test"; + +test("/api/assets/:id/url returns 404 when requested variant missing", async () => { + const { pickVariantKey } = await import( + "../../app/api/assets/[id]/url/variant", + ); + const key = pickVariantKey({ variants: [] }, { kind: "thumb", size: 256 }); + expect(key).toBeNull(); +}); diff --git a/apps/worker/src/jobs.ts b/apps/worker/src/jobs.ts index 858b973..eee8e8c 100644 --- a/apps/worker/src/jobs.ts +++ b/apps/worker/src/jobs.ts @@ -205,6 +205,35 @@ async function uploadObject(input: { ); } +async function upsertVariant(input: { + assetId: string; + kind: "thumb" | "poster" | "video_mp4"; + size: number; + key: string; + mimeType: string; + width?: number | null; + height?: number | null; +}) { + const db = getDb(); + await db` + insert into asset_variants (asset_id, kind, size, key, mime_type, width, height) + values ( + ${input.assetId}, + ${input.kind}, + ${input.size}, + ${input.key}, + ${input.mimeType}, + ${input.width ?? null}, + ${input.height ?? null} + ) + on conflict (asset_id, kind, size) + do update set key = excluded.key, + mime_type = excluded.mime_type, + width = excluded.width, + height = excluded.height + `; +} + async function getObjectLastModified(input: { bucket: string; key: string }): Promise { const s3 = getMinioInternalClient(); const res = await s3.send(new HeadObjectCommand({ Bucket: input.bucket, Key: input.key })); @@ -424,6 +453,24 @@ export async function handleProcessAsset(raw: unknown) { filePath: thumb768Path, contentType: "image/jpeg", }); + await upsertVariant({ + assetId: asset.id, + kind: "thumb", + size: 256, + key: thumb256Key, + mimeType: "image/jpeg", + width: typeof updates.width === "number" ? updates.width : null, + height: typeof updates.height === "number" ? updates.height : null, + }); + await upsertVariant({ + assetId: asset.id, + kind: "thumb", + size: 768, + key: thumb768Key, + mimeType: "image/jpeg", + width: typeof updates.width === "number" ? updates.width : null, + height: typeof updates.height === "number" ? updates.height : null, + }); updates.thumb_small_key = thumb256Key; updates.thumb_med_key = thumb768Key; } else if (asset.media_type === "video") { @@ -485,6 +532,15 @@ export async function handleProcessAsset(raw: unknown) { filePath: posterPath, contentType: "image/jpeg", }); + await upsertVariant({ + assetId: asset.id, + kind: "poster", + size: 256, + key: posterKey, + mimeType: "image/jpeg", + width: typeof updates.width === "number" ? updates.width : null, + height: typeof updates.height === "number" ? updates.height : null, + }); updates.poster_key = posterKey; } diff --git a/packages/db/migrations/0003_asset_variants.sql b/packages/db/migrations/0003_asset_variants.sql new file mode 100644 index 0000000..d5a4d42 --- /dev/null +++ b/packages/db/migrations/0003_asset_variants.sql @@ -0,0 +1,20 @@ +CREATE TYPE IF NOT EXISTS asset_variant_kind AS ENUM ( + 'thumb', + 'poster', + 'video_mp4' +); + +CREATE TABLE IF NOT EXISTS asset_variants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + kind asset_variant_kind NOT NULL, + size int NOT NULL, + key text NOT NULL, + mime_type text NOT NULL, + width int, + height int, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(asset_id, kind, size) +); + +CREATE INDEX IF NOT EXISTS asset_variants_asset_id_idx ON asset_variants(asset_id);