Initial commit
This commit is contained in:
137
apps/web/app/api/tree/route.ts
Normal file
137
apps/web/app/api/tree/route.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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(),
|
||||
granularity: z.enum(["year", "month", "day"]).default("day"),
|
||||
mediaType: z.enum(["image", "video"]).optional(),
|
||||
includeFailed: z.enum(["0", "1"]).default("0").transform((v) => v === "1"),
|
||||
limit: z.coerce.number().int().positive().max(500).default(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type Granularity = z.infer<typeof querySchema>["granularity"];
|
||||
|
||||
function sqlGroupExpr(granularity: Granularity, alias: string) {
|
||||
const col = `${alias}.capture_ts_utc`;
|
||||
if (granularity === "year") return `date_trunc('year', ${col})`;
|
||||
if (granularity === "month") return `date_trunc('month', ${col})`;
|
||||
return `date_trunc('day', ${col})`;
|
||||
}
|
||||
|
||||
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,
|
||||
granularity: url.searchParams.get("granularity") ?? undefined,
|
||||
mediaType: url.searchParams.get("mediaType") ?? undefined,
|
||||
includeFailed: url.searchParams.get("includeFailed") ?? undefined,
|
||||
limit: url.searchParams.get("limit") ?? 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();
|
||||
|
||||
// Note: capture_ts_utc can be null (unprocessed). Those rows are excluded.
|
||||
const groupExprFiltered = sqlGroupExpr(query.granularity, "filtered");
|
||||
const groupExprF = sqlGroupExpr(query.granularity, "f");
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
bucket: string;
|
||||
group_ts: string;
|
||||
count_total: number;
|
||||
count_ready: number;
|
||||
sample_asset_id: string | null;
|
||||
sample_thumb_small_key: string | null;
|
||||
sample_thumb_med_key: string | null;
|
||||
sample_poster_key: string | null;
|
||||
sample_active_key: string | null;
|
||||
sample_status: string | null;
|
||||
sample_media_type: "image" | "video" | null;
|
||||
}[]
|
||||
>`
|
||||
with filtered as (
|
||||
select
|
||||
id,
|
||||
bucket,
|
||||
media_type,
|
||||
status,
|
||||
capture_ts_utc,
|
||||
active_key,
|
||||
thumb_small_key,
|
||||
thumb_med_key,
|
||||
poster_key
|
||||
from assets
|
||||
where capture_ts_utc is not null
|
||||
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
|
||||
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
|
||||
and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type)
|
||||
and (
|
||||
${query.includeFailed}::boolean = true
|
||||
or status <> 'failed'
|
||||
)
|
||||
),
|
||||
grouped as (
|
||||
select
|
||||
bucket,
|
||||
${db.unsafe(groupExprFiltered)} as group_ts,
|
||||
count(*)::int as count_total,
|
||||
count(*) filter (where status = 'ready')::int as count_ready
|
||||
from filtered
|
||||
group by bucket, ${db.unsafe(groupExprFiltered)}
|
||||
order by group_ts desc
|
||||
limit ${query.limit}
|
||||
)
|
||||
select
|
||||
g.bucket,
|
||||
to_char(g.group_ts AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') as group_ts,
|
||||
g.count_total,
|
||||
g.count_ready,
|
||||
s.id as sample_asset_id,
|
||||
s.thumb_small_key as sample_thumb_small_key,
|
||||
s.thumb_med_key as sample_thumb_med_key,
|
||||
s.poster_key as sample_poster_key,
|
||||
s.active_key as sample_active_key,
|
||||
s.status as sample_status,
|
||||
s.media_type as sample_media_type
|
||||
from grouped g
|
||||
left join lateral (
|
||||
select *
|
||||
from filtered f
|
||||
where f.bucket = g.bucket
|
||||
and ${db.unsafe(groupExprF)} = g.group_ts
|
||||
and f.status = 'ready'
|
||||
order by f.capture_ts_utc asc
|
||||
limit 1
|
||||
) s on true
|
||||
order by g.group_ts desc
|
||||
`;
|
||||
|
||||
return Response.json({
|
||||
granularity: query.granularity,
|
||||
start: start ? start.toISOString() : null,
|
||||
end: end ? end.toISOString() : null,
|
||||
mediaType: query.mediaType ?? null,
|
||||
includeFailed: query.includeFailed,
|
||||
nodes: rows,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user