Files
porthole/apps/web/app/api/assets/route.ts
2026-02-02 21:21:11 -08:00

117 lines
3.8 KiB
TypeScript

import { z } from "zod";
import { getDb } from "@tline/db";
export const runtime = "nodejs";
const querySchema = z
.object({
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
mediaType: z.enum(["image", "video"]).optional(),
status: z.enum(["new", "processing", "ready", "failed"]).optional(),
limit: z.coerce.number().int().positive().max(200).default(60),
cursor: z.string().uuid().optional(),
cursorTs: z.string().datetime().optional(),
})
.strict();
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const parsed = querySchema.safeParse({
start: url.searchParams.get("start") ?? undefined,
end: url.searchParams.get("end") ?? undefined,
mediaType: url.searchParams.get("mediaType") ?? undefined,
status: url.searchParams.get("status") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
cursor: url.searchParams.get("cursor") ?? undefined,
cursorTs: url.searchParams.get("cursorTs") ?? undefined,
});
if (!parsed.success) {
return Response.json(
{ error: "invalid_query", issues: parsed.error.issues },
{ status: 400 },
);
}
const query = parsed.data;
const start = query.start ? new Date(query.start) : null;
const end = query.end ? new Date(query.end) : null;
const db = getDb();
// Cursor pagination: (capture_ts_utc, id) > (cursorTs, cursor)
const cursorTs = query.cursorTs ? new Date(query.cursorTs) : null;
const cursorId = query.cursor ?? null;
const rows = await db<
{
id: string;
bucket: string;
media_type: "image" | "video";
mime_type: string;
active_key: string;
capture_ts_utc: string | null;
date_confidence: string | null;
width: number | null;
height: number | null;
rotation: number | null;
duration_seconds: number | null;
thumb_small_key: string | null;
thumb_med_key: string | null;
poster_key: string | null;
status: "new" | "processing" | "ready" | "failed";
error_message: string | null;
}[]
>`
select
a.id,
a.bucket,
a.media_type,
a.mime_type,
a.active_key,
coalesce(o.capture_ts_utc_override, a.capture_ts_utc) as capture_ts_utc,
a.date_confidence,
a.width,
a.height,
a.rotation,
a.duration_seconds,
a.thumb_small_key,
a.thumb_med_key,
a.poster_key,
a.status,
a.error_message
from assets a
left join asset_overrides o
on o.asset_id = a.id
where true
and coalesce(o.capture_ts_utc_override, a.capture_ts_utc) is not null
and (${start}::timestamptz is null or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) >= ${start}::timestamptz)
and (${end}::timestamptz is null or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) < ${end}::timestamptz)
and (${query.mediaType ?? null}::media_type is null or a.media_type = ${query.mediaType ?? null}::media_type)
and (${query.status ?? null}::asset_status is null or a.status = ${query.status ?? null}::asset_status)
and (
${cursorId}::uuid is null
or ${cursorTs}::timestamptz is null
or (coalesce(o.capture_ts_utc_override, a.capture_ts_utc), a.id) > (${cursorTs}::timestamptz, ${cursorId}::uuid)
)
order by coalesce(o.capture_ts_utc_override, a.capture_ts_utc) asc nulls last, a.id asc
limit ${query.limit}
`;
const nextCursor = rows.length > 0 ? rows[rows.length - 1] : null;
return Response.json({
start: start ? start.toISOString() : null,
end: end ? end.toISOString() : null,
items: rows,
next:
nextCursor && nextCursor.capture_ts_utc
? { cursor: nextCursor.id, cursorTs: nextCursor.capture_ts_utc }
: null,
});
}