feat: add asset variants table and URL selection
This commit is contained in:
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { getDb } from "@tline/db";
|
import { getDb } from "@tline/db";
|
||||||
import { presignGetObjectUrl } from "@tline/minio";
|
import { presignGetObjectUrl } from "@tline/minio";
|
||||||
|
import { pickVariantKey } from "./variant";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -9,7 +10,15 @@ const paramsSchema = z.object({
|
|||||||
id: z.string().uuid()
|
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(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -26,14 +35,46 @@ export async function GET(
|
|||||||
const params = paramsParsed.data;
|
const params = paramsParsed.data;
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const variantParsed = variantSchema.safeParse(url.searchParams.get("variant") ?? "original");
|
const kindParam = url.searchParams.get("kind");
|
||||||
if (!variantParsed.success) {
|
const sizeParam = url.searchParams.get("size");
|
||||||
return Response.json(
|
const legacyVariantParam = url.searchParams.get("variant");
|
||||||
{ error: "invalid_query", issues: variantParsed.error.issues },
|
|
||||||
{ status: 400 },
|
let requestedKind: z.infer<typeof kindSchema> = "original";
|
||||||
);
|
let requestedSize: number | null = null;
|
||||||
|
let legacyVariant: z.infer<typeof legacyVariantSchema> | 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 db = getDb();
|
||||||
const rows = await db<
|
const rows = await db<
|
||||||
@@ -52,32 +93,70 @@ export async function GET(
|
|||||||
limit 1
|
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];
|
const asset = rows[0];
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return Response.json({ error: "not_found" }, { status: 404 });
|
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 =
|
const key =
|
||||||
variant === "original"
|
requestedKind === "original"
|
||||||
? asset.active_key
|
? asset.active_key
|
||||||
: variant === "thumb_small"
|
: requestedSize !== null
|
||||||
? asset.thumb_small_key
|
? pickVariantKey(
|
||||||
: variant === "thumb_med"
|
{ variants },
|
||||||
? asset.thumb_med_key
|
{ kind: requestedKind, size: requestedSize },
|
||||||
: asset.poster_key;
|
) ?? legacyKey
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "variant_not_available", variant },
|
{ error: "variant_not_available", kind: requestedKind, size: requestedSize },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hint the browser; especially helpful for Range playback.
|
// 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 =
|
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({
|
const signed = await presignGetObjectUrl({
|
||||||
bucket: asset.bucket,
|
bucket: asset.bucket,
|
||||||
|
|||||||
9
apps/web/app/api/assets/[id]/url/variant.ts
Normal file
9
apps/web/app/api/assets/[id]/url/variant.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
9
apps/web/src/__tests__/variant-url-404.test.ts
Normal file
9
apps/web/src/__tests__/variant-url-404.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
@@ -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<Date | null> {
|
async function getObjectLastModified(input: { bucket: string; key: string }): Promise<Date | null> {
|
||||||
const s3 = getMinioInternalClient();
|
const s3 = getMinioInternalClient();
|
||||||
const res = await s3.send(new HeadObjectCommand({ Bucket: input.bucket, Key: input.key }));
|
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,
|
filePath: thumb768Path,
|
||||||
contentType: "image/jpeg",
|
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_small_key = thumb256Key;
|
||||||
updates.thumb_med_key = thumb768Key;
|
updates.thumb_med_key = thumb768Key;
|
||||||
} else if (asset.media_type === "video") {
|
} else if (asset.media_type === "video") {
|
||||||
@@ -485,6 +532,15 @@ export async function handleProcessAsset(raw: unknown) {
|
|||||||
filePath: posterPath,
|
filePath: posterPath,
|
||||||
contentType: "image/jpeg",
|
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;
|
updates.poster_key = posterKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
packages/db/migrations/0003_asset_variants.sql
Normal file
20
packages/db/migrations/0003_asset_variants.sql
Normal file
@@ -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);
|
||||||
Reference in New Issue
Block a user