Initial commit
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM oven/bun:1.3.3 AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace manifests (copy all workspace package.json files so Bun
|
||||
# can resolve workspace:* deps without mutating the lockfile).
|
||||
COPY package.json bun.lock tsconfig.base.json ./
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
COPY apps/worker/package.json ./apps/worker/package.json
|
||||
COPY packages/config/package.json ./packages/config/package.json
|
||||
COPY packages/db/package.json ./packages/db/package.json
|
||||
COPY packages/minio/package.json ./packages/minio/package.json
|
||||
COPY packages/queue/package.json ./packages/queue/package.json
|
||||
|
||||
RUN bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
FROM deps AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY apps/web ./apps/web
|
||||
COPY packages ./packages
|
||||
|
||||
# Build Next standalone output
|
||||
RUN bun run --cwd apps/web build
|
||||
|
||||
FROM node:20-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Next standalone output contains its own node_modules tree.
|
||||
# With outputFileTracingRoot set, standalone output nests under the basename
|
||||
# of that tracing root ("app" inside this Docker build).
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./app/apps/web/.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "app/apps/web/server.js"]
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<main style={{ padding: 16 }}>
|
||||
<h1 style={{ marginTop: 0 }}>Admin</h1>
|
||||
<p>Upload + scan tools will live here.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { presignGetObjectUrl } from "@tline/minio";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string().uuid()
|
||||
});
|
||||
|
||||
const variantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]);
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
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 variant = variantParsed.data;
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
bucket: string;
|
||||
active_key: string;
|
||||
thumb_small_key: string | null;
|
||||
thumb_med_key: string | null;
|
||||
poster_key: string | null;
|
||||
mime_type: string;
|
||||
}[]
|
||||
>`
|
||||
select bucket, active_key, thumb_small_key, thumb_med_key, poster_key, mime_type
|
||||
from assets
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const asset = rows[0];
|
||||
if (!asset) {
|
||||
return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const key =
|
||||
variant === "original"
|
||||
? asset.active_key
|
||||
: variant === "thumb_small"
|
||||
? asset.thumb_small_key
|
||||
: variant === "thumb_med"
|
||||
? asset.thumb_med_key
|
||||
: asset.poster_key;
|
||||
|
||||
if (!key) {
|
||||
return Response.json(
|
||||
{ error: "variant_not_available", variant },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hint the browser; especially helpful for Range playback.
|
||||
const responseContentType = variant === "original" ? asset.mime_type : "image/jpeg";
|
||||
|
||||
const responseContentDisposition =
|
||||
variant === "original" && asset.mime_type.startsWith("video/") ? "inline" : undefined;
|
||||
|
||||
const signed = await presignGetObjectUrl({
|
||||
bucket: asset.bucket,
|
||||
key,
|
||||
responseContentType,
|
||||
responseContentDisposition,
|
||||
});
|
||||
|
||||
return Response.json(signed, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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
|
||||
id,
|
||||
bucket,
|
||||
media_type,
|
||||
mime_type,
|
||||
active_key,
|
||||
capture_ts_utc,
|
||||
date_confidence,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
duration_seconds,
|
||||
thumb_small_key,
|
||||
thumb_med_key,
|
||||
poster_key,
|
||||
status,
|
||||
error_message
|
||||
from assets
|
||||
where true
|
||||
and 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.status ?? null}::asset_status is null or status = ${query.status ?? null}::asset_status)
|
||||
and (
|
||||
${cursorId}::uuid is null
|
||||
or ${cursorTs}::timestamptz is null
|
||||
or (capture_ts_utc, id) > (${cursorTs}::timestamptz, ${cursorId}::uuid)
|
||||
)
|
||||
order by capture_ts_utc asc nulls last, 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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function GET() {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { getMinioBucket } from "@tline/minio";
|
||||
import { enqueueScanMinioPrefix } from "@tline/queue";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
bucket: z.string().min(1).optional(),
|
||||
prefix: z.string().min(1).default("originals/"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const body = bodySchema.parse(bodyJson);
|
||||
|
||||
const bucket = body.bucket ?? getMinioBucket();
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
}[]
|
||||
>`
|
||||
select id
|
||||
from imports
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const imp = rows[0];
|
||||
if (!imp) {
|
||||
return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await enqueueScanMinioPrefix({
|
||||
importId: imp.id,
|
||||
bucket,
|
||||
prefix: body.prefix,
|
||||
});
|
||||
|
||||
await db`
|
||||
update imports
|
||||
set status = 'queued'
|
||||
where id = ${imp.id}
|
||||
`;
|
||||
|
||||
return Response.json({ ok: true, importId: imp.id, bucket, prefix: body.prefix });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
const db = getDb();
|
||||
|
||||
const [imp] = await db<
|
||||
{
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
total_count: number | null;
|
||||
processed_count: number | null;
|
||||
failed_count: number | null;
|
||||
}[]
|
||||
>`
|
||||
select id, type, status, created_at, total_count, processed_count, failed_count
|
||||
from imports
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!imp) {
|
||||
return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const counts = await db<
|
||||
{
|
||||
total: number;
|
||||
ready: number;
|
||||
failed: number;
|
||||
processing: number;
|
||||
new_count: number;
|
||||
}[]
|
||||
>`
|
||||
select
|
||||
count(*)::int as total,
|
||||
count(*) filter (where status = 'ready')::int as ready,
|
||||
count(*) filter (where status = 'failed')::int as failed,
|
||||
count(*) filter (where status = 'processing')::int as processing,
|
||||
count(*) filter (where status = 'new')::int as new_count
|
||||
from assets
|
||||
where source_key like ${`staging/${imp.id}/%`}
|
||||
`;
|
||||
|
||||
return Response.json({ ...imp, asset_counts: counts[0] ?? null });
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { Readable } from "stream";
|
||||
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { getMinioBucket, getMinioInternalClient } from "@tline/minio";
|
||||
import { enqueueProcessAsset } from "@tline/queue";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
const contentTypeMediaMap: Array<{
|
||||
match: (ct: string) => boolean;
|
||||
mediaType: "image" | "video";
|
||||
}> = [
|
||||
{ match: (ct) => ct.startsWith("image/"), mediaType: "image" },
|
||||
{ match: (ct) => ct.startsWith("video/"), mediaType: "video" },
|
||||
];
|
||||
|
||||
function inferMediaTypeFromContentType(ct: string): "image" | "video" | null {
|
||||
const found = contentTypeMediaMap.find((m) => m.match(ct));
|
||||
return found?.mediaType ?? null;
|
||||
}
|
||||
|
||||
function inferExtFromContentType(ct: string): string {
|
||||
const parts = ct.split("/");
|
||||
const ext = parts[1] ?? "bin";
|
||||
return ext.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "bin";
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "application/octet-stream";
|
||||
const mediaType = inferMediaTypeFromContentType(contentType);
|
||||
if (!mediaType) {
|
||||
return Response.json({ error: "unsupported_content_type", contentType }, { status: 400 });
|
||||
}
|
||||
|
||||
const bucket = getMinioBucket();
|
||||
const ext = inferExtFromContentType(contentType);
|
||||
const objectId = randomUUID();
|
||||
const key = `staging/${params.id}/${objectId}.${ext}`;
|
||||
|
||||
const db = getDb();
|
||||
const [imp] = await db<{ id: string }[]>`
|
||||
select id
|
||||
from imports
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!imp) {
|
||||
return Response.json({ error: "import_not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!request.body) {
|
||||
return Response.json({ error: "missing_body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const s3 = getMinioInternalClient();
|
||||
const bodyStream = Readable.fromWeb(request.body as unknown as NodeReadableStream);
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: bodyStream,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
}[]
|
||||
>`
|
||||
insert into assets (bucket, media_type, mime_type, source_key, active_key)
|
||||
values (${bucket}, ${mediaType}, ${contentType}, ${key}, ${key})
|
||||
on conflict (bucket, source_key)
|
||||
do update set active_key = excluded.active_key
|
||||
returning id, status
|
||||
`;
|
||||
|
||||
const asset = rows[0];
|
||||
if (!asset) {
|
||||
return Response.json({ error: "asset_insert_failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
await enqueueProcessAsset({ assetId: asset.id });
|
||||
|
||||
return Response.json({ ok: true, importId: imp.id, assetId: asset.id, bucket, key });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
type: z.enum(["upload", "minio_scan"]).default("upload"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const body = bodySchema.parse(bodyJson);
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
type: "upload" | "minio_scan";
|
||||
status: string;
|
||||
created_at: string;
|
||||
}[]
|
||||
>`
|
||||
insert into imports (type, status)
|
||||
values (${body.type}, 'new')
|
||||
returning id, type, status, created_at
|
||||
`;
|
||||
|
||||
const created = rows[0];
|
||||
if (!created) {
|
||||
return Response.json({ error: "insert_failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json(created);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Asset = {
|
||||
id: string;
|
||||
media_type: "image" | "video";
|
||||
mime_type: string;
|
||||
capture_ts_utc: string | null;
|
||||
thumb_small_key: string | null;
|
||||
thumb_med_key: string | null;
|
||||
poster_key: string | null;
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
type AssetsResponse = {
|
||||
items: Asset[];
|
||||
};
|
||||
|
||||
type SignedUrlResponse = {
|
||||
url: string;
|
||||
expiresSeconds: number;
|
||||
};
|
||||
|
||||
type PreviewUrlState = Record<string, string | undefined>;
|
||||
|
||||
function startOfDayUtc(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0));
|
||||
}
|
||||
|
||||
function endOfDayUtc(iso: string) {
|
||||
const start = startOfDayUtc(iso);
|
||||
return new Date(start.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
const [assets, setAssets] = useState<Asset[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previews, setPreviews] = useState<PreviewUrlState>({});
|
||||
|
||||
const [viewer, setViewer] = useState<{
|
||||
asset: Asset;
|
||||
url: string;
|
||||
variant: "original" | "thumb_med" | "poster";
|
||||
} | null>(null);
|
||||
|
||||
const [viewerError, setViewerError] = useState<string | null>(null);
|
||||
const [videoFallback, setVideoFallback] = useState<{ posterUrl: string | null } | null>(null);
|
||||
|
||||
const range = useMemo(() => {
|
||||
if (!props.selectedDayIso) return null;
|
||||
const start = startOfDayUtc(props.selectedDayIso);
|
||||
const end = endOfDayUtc(props.selectedDayIso);
|
||||
return { start, end };
|
||||
}, [props.selectedDayIso]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
if (!range) {
|
||||
setAssets(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setAssets(null);
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
start: range.start.toISOString(),
|
||||
end: range.end.toISOString(),
|
||||
limit: "120",
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/assets?${qs.toString()}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`assets_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as AssetsResponse;
|
||||
|
||||
if (!cancelled) {
|
||||
setAssets(json.items);
|
||||
setPreviews({});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
async function loadSignedUrl(assetId: string, variant: "original" | "thumb_small" | "thumb_med" | "poster") {
|
||||
const res = await fetch(`/api/assets/${assetId}/url?variant=${variant}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`presign_failed:${res.status}`);
|
||||
const json = (await res.json()) as SignedUrlResponse;
|
||||
return json.url;
|
||||
}
|
||||
|
||||
async function openViewer(asset: Asset) {
|
||||
if (asset.status === "failed") {
|
||||
setViewerError(`${asset.id}: ${asset.error_message ?? "asset_failed"}`);
|
||||
setViewer(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
|
||||
const variant: "original" | "thumb_med" | "poster" = "original";
|
||||
const url = await loadSignedUrl(asset.id, variant);
|
||||
setViewer({ asset, url, variant });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
|
||||
<strong>Media</strong>
|
||||
<span style={{ color: "#666", fontSize: 12 }}>
|
||||
{props.selectedDayIso ? startOfDayUtc(props.selectedDayIso).toISOString().slice(0, 10) : "(select a day)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
||||
{!assets && props.selectedDayIso && !error ? (
|
||||
<div style={{ color: "#666" }}>Loading assets…</div>
|
||||
) : null}
|
||||
|
||||
{assets ? (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{assets.length === 0 ? <div style={{ color: "#666" }}>No assets.</div> : null}
|
||||
{assets.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => void openViewer(a)}
|
||||
onPointerEnter={() => {
|
||||
if (previews[a.id] !== undefined) return;
|
||||
|
||||
const variant = a.media_type === "image" ? "thumb_small" : "poster";
|
||||
const promise = loadSignedUrl(a.id, variant).catch(() => undefined);
|
||||
|
||||
void promise.then((url) => {
|
||||
setPreviews((prev) => ({ ...prev, [a.id]: url }));
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: 10,
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 8,
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "72px 1fr", gap: 10, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 8,
|
||||
background: "#f2f2f2",
|
||||
border: "1px solid #eee",
|
||||
overflow: "hidden",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "#888",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{previews[a.id] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previews[a.id]} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
) : (
|
||||
<span>{a.media_type}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
||||
<span>
|
||||
{a.media_type} · {a.status}
|
||||
</span>
|
||||
<span style={{ color: "#666", fontSize: 12 }}>{a.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
{a.status === "failed" && a.error_message ? (
|
||||
<div style={{ color: "#b00", fontSize: 12, marginTop: 6 }}>{a.error_message}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{viewer || viewerError ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={() => {
|
||||
setViewer(null);
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "min(1000px, 98vw)",
|
||||
maxHeight: "90vh",
|
||||
overflow: "auto",
|
||||
background: "white",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<strong>{viewer ? `${viewer.asset.media_type} (${viewer.variant})` : "Viewer"}</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewer(null);
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewer ? (
|
||||
<>
|
||||
{viewer.asset.media_type === "image" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={viewer.url}
|
||||
alt={viewer.asset.id}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
onError={() => setViewerError("image_load_failed")}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={viewer.url}
|
||||
controls
|
||||
style={{ width: "100%" }}
|
||||
poster={videoFallback?.posterUrl ?? undefined}
|
||||
onError={() => {
|
||||
setViewerError("video_playback_failed");
|
||||
if (videoFallback !== null) return;
|
||||
setVideoFallback({ posterUrl: null });
|
||||
void loadSignedUrl(viewer.asset.id, "poster")
|
||||
.then((posterUrl) => setVideoFallback({ posterUrl }))
|
||||
.catch(() => setVideoFallback({ posterUrl: null }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewerError ? (
|
||||
<div style={{ color: "#b00", fontSize: 12 }}>
|
||||
{viewerError}
|
||||
{viewer.asset.media_type === "video" ? " (try a different browser/codec)" : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ color: "#666", fontSize: 12 }}>{viewer.asset.id}</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#b00" }}>{viewerError ?? "unknown_error"}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Granularity = "year" | "month" | "day";
|
||||
|
||||
type TreeNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
ts: string; // ISO string from API
|
||||
countTotal: number;
|
||||
countReady: number;
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
type ApiTreeRow = {
|
||||
bucket: string;
|
||||
group_ts: string;
|
||||
count_total: number;
|
||||
count_ready: number;
|
||||
};
|
||||
|
||||
type ApiTreeResponse = {
|
||||
granularity: Granularity;
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
nodes: ApiTreeRow[];
|
||||
};
|
||||
|
||||
type Orientation = "vertical" | "horizontal";
|
||||
|
||||
type ExpandedState = Record<string, boolean>;
|
||||
|
||||
function monthKey(d: Date) {
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function dayKey(d: Date) {
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function yearKey(d: Date) {
|
||||
return `${d.getUTCFullYear()}`;
|
||||
}
|
||||
|
||||
function buildHierarchy(dayRows: ApiTreeRow[]): TreeNode[] {
|
||||
const years = new Map<string, TreeNode>();
|
||||
const months = new Map<string, TreeNode>();
|
||||
|
||||
for (const row of dayRows) {
|
||||
const date = new Date(row.group_ts);
|
||||
const year = yearKey(date);
|
||||
const month = monthKey(date);
|
||||
const day = dayKey(date);
|
||||
|
||||
const yMapKey = `${row.bucket}:${year}`;
|
||||
const mMapKey = `${row.bucket}:${month}`;
|
||||
|
||||
let yNode = years.get(yMapKey);
|
||||
if (!yNode) {
|
||||
yNode = {
|
||||
id: `y:${year}:${row.bucket}`,
|
||||
label: year,
|
||||
ts: new Date(Date.UTC(Number(year), 0, 1)).toISOString(),
|
||||
countTotal: 0,
|
||||
countReady: 0,
|
||||
children: [],
|
||||
};
|
||||
years.set(yMapKey, yNode);
|
||||
}
|
||||
|
||||
let mNode = months.get(mMapKey);
|
||||
if (!mNode) {
|
||||
const [yy, mm] = month.split("-");
|
||||
mNode = {
|
||||
id: `m:${month}:${row.bucket}`,
|
||||
label: `${yy}-${mm}`,
|
||||
ts: new Date(Date.UTC(Number(yy), Number(mm) - 1, 1)).toISOString(),
|
||||
countTotal: 0,
|
||||
countReady: 0,
|
||||
children: [],
|
||||
};
|
||||
months.set(mMapKey, mNode);
|
||||
yNode.children!.push(mNode);
|
||||
}
|
||||
|
||||
const dNode: TreeNode = {
|
||||
id: `d:${day}:${row.bucket}`,
|
||||
label: day,
|
||||
ts: row.group_ts,
|
||||
countTotal: row.count_total,
|
||||
countReady: row.count_ready,
|
||||
};
|
||||
|
||||
mNode.children!.push(dNode);
|
||||
|
||||
// rollups
|
||||
mNode.countTotal += row.count_total;
|
||||
mNode.countReady += row.count_ready;
|
||||
yNode.countTotal += row.count_total;
|
||||
yNode.countReady += row.count_ready;
|
||||
}
|
||||
|
||||
// Sort ascending within each parent
|
||||
const yearNodes = Array.from(years.values()).sort((a, b) => a.label.localeCompare(b.label));
|
||||
for (const y of yearNodes) {
|
||||
y.children = (y.children ?? []).sort((a, b) => a.label.localeCompare(b.label));
|
||||
for (const m of y.children) {
|
||||
m.children = (m.children ?? []).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
}
|
||||
|
||||
return yearNodes;
|
||||
}
|
||||
|
||||
function gatherVisible(
|
||||
roots: TreeNode[],
|
||||
expanded: ExpandedState,
|
||||
): Array<{ node: TreeNode; depth: number; parentId: string | null }> {
|
||||
const out: Array<{ node: TreeNode; depth: number; parentId: string | null }> = [];
|
||||
|
||||
function walk(nodes: TreeNode[], depth: number, parentId: string | null) {
|
||||
for (const n of nodes) {
|
||||
out.push({ node: n, depth, parentId });
|
||||
const isExpanded = expanded[n.id] ?? depth < 1; // default expand years
|
||||
if (n.children?.length && isExpanded) {
|
||||
walk(n.children, depth + 1, n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(roots, 0, null);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void }) {
|
||||
const [orientation, setOrientation] = useState<Orientation>("vertical");
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// simple pan/zoom via viewBox
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 1200, h: 800 });
|
||||
const dragState = useRef<{
|
||||
active: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startViewBox: typeof viewBox;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch("/api/tree?granularity=day&limit=500&includeFailed=1", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`tree_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as ApiTreeResponse;
|
||||
if (!cancelled) setRows(json.nodes);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]);
|
||||
const visible = useMemo(() => gatherVisible(roots, expanded), [roots, expanded]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const nodeGap = 56;
|
||||
const depthGap = 180;
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const { node, depth } = visible[i];
|
||||
const primary = i * nodeGap;
|
||||
const secondary = depth * depthGap;
|
||||
|
||||
const x = orientation === "vertical" ? secondary : primary;
|
||||
const y = orientation === "vertical" ? primary : secondary;
|
||||
|
||||
positions.set(node.id, { x, y });
|
||||
}
|
||||
|
||||
// compute bounds
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
for (const p of positions.values()) {
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
|
||||
const padding = 120;
|
||||
const w = maxX + padding * 2;
|
||||
const h = maxY + padding * 2;
|
||||
|
||||
return { positions, w, h, padding };
|
||||
}, [visible, orientation]);
|
||||
|
||||
useEffect(() => {
|
||||
// reset viewBox when layout changes
|
||||
setViewBox((vb) => ({ ...vb, w: Math.max(layout.w, 1200), h: Math.max(layout.h, 800) }));
|
||||
}, [layout.w, layout.h]);
|
||||
|
||||
function toggleNode(id: string) {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !(prev[id] ?? false) }));
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent<SVGSVGElement>) {
|
||||
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
|
||||
dragState.current = {
|
||||
active: true,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startViewBox: viewBox,
|
||||
};
|
||||
}
|
||||
|
||||
function onPointerMove(e: React.PointerEvent<SVGSVGElement>) {
|
||||
if (!dragState.current?.active) return;
|
||||
const dx = e.clientX - dragState.current.startX;
|
||||
const dy = e.clientY - dragState.current.startY;
|
||||
|
||||
// Convert pixels to viewBox units roughly; this is intentionally simple.
|
||||
const scaleX = viewBox.w / 900;
|
||||
const scaleY = viewBox.h / 600;
|
||||
|
||||
setViewBox({
|
||||
...viewBox,
|
||||
x: dragState.current.startViewBox.x - dx * scaleX,
|
||||
y: dragState.current.startViewBox.y - dy * scaleY,
|
||||
});
|
||||
}
|
||||
|
||||
function onPointerUp(e: React.PointerEvent<SVGSVGElement>) {
|
||||
dragState.current = null;
|
||||
try {
|
||||
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function onWheel(e: React.WheelEvent<SVGSVGElement>) {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 0.9 : 1.1;
|
||||
|
||||
const mouseX = e.clientX;
|
||||
const mouseY = e.clientY;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
const px = (mouseX - rect.left) / rect.width;
|
||||
const py = (mouseY - rect.top) / rect.height;
|
||||
|
||||
const newW = Math.max(300, Math.min(5000, viewBox.w * factor));
|
||||
const newH = Math.max(300, Math.min(5000, viewBox.h * factor));
|
||||
|
||||
const newX = viewBox.x + (viewBox.w - newW) * px;
|
||||
const newY = viewBox.y + (viewBox.h - newH) * py;
|
||||
|
||||
setViewBox({ x: newX, y: newY, w: newW, h: newH });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<strong>Timeline</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrientation((o) => (o === "vertical" ? "horizontal" : "vertical"))}
|
||||
>
|
||||
Orientation: {orientation}
|
||||
</button>
|
||||
<button type="button" onClick={() => setViewBox({ x: 0, y: 0, w: 1200, h: 800 })}>
|
||||
Reset view
|
||||
</button>
|
||||
{rows ? <span style={{ color: "#666" }}>{rows.length} day nodes</span> : null}
|
||||
</div>
|
||||
|
||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
||||
{!rows && !error ? <div style={{ color: "#666" }}>Loading tree…</div> : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
height: 600,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onWheel={onWheel}
|
||||
style={{ touchAction: "none", background: "#fafafa" }}
|
||||
>
|
||||
<g transform={`translate(${layout.padding} ${layout.padding})`}>
|
||||
{/* edges */}
|
||||
{visible.map(({ node, parentId }) => {
|
||||
if (!parentId) return null;
|
||||
const p = layout.positions.get(parentId);
|
||||
const c = layout.positions.get(node.id);
|
||||
if (!p || !c) return null;
|
||||
return (
|
||||
<line
|
||||
key={`${parentId}->${node.id}`}
|
||||
x1={p.x}
|
||||
y1={p.y}
|
||||
x2={c.x}
|
||||
y2={c.y}
|
||||
stroke="#bbb"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* nodes */}
|
||||
{visible.map(({ node }) => {
|
||||
const pos = layout.positions.get(node.id);
|
||||
if (!pos) return null;
|
||||
|
||||
const hasChildren = Boolean(node.children?.length);
|
||||
const isExpanded = expanded[node.id] ?? node.id.startsWith("y:");
|
||||
|
||||
const isDay = node.id.startsWith("d:");
|
||||
const clickCursor = hasChildren || isDay ? "pointer" : "default";
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${pos.x} ${pos.y})`}
|
||||
style={{ cursor: clickCursor }}
|
||||
onClick={() => {
|
||||
if (hasChildren) toggleNode(node.id);
|
||||
if (isDay) props.onSelectDay?.(node.ts);
|
||||
}}
|
||||
>
|
||||
<circle r={12} fill={hasChildren ? "#1f6feb" : "#666"} />
|
||||
<text x={20} y={5} fontSize={14} fill="#111">
|
||||
{node.label} ({node.countReady}/{node.countTotal})
|
||||
{hasChildren ? (isExpanded ? " ▼" : " ▶") : ""}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div style={{ color: "#666", fontSize: 12 }}>
|
||||
Drag to pan, mouse wheel to zoom. Click years/months to expand.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { getAppName } from "@tline/config";
|
||||
|
||||
export const metadata = {
|
||||
title: getAppName()
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { getAppName } from "@tline/config";
|
||||
import { useState } from "react";
|
||||
|
||||
import { MediaPanel } from "./components/MediaPanel";
|
||||
import { TimelineTree } from "./components/TimelineTree";
|
||||
|
||||
export default function HomePage() {
|
||||
const [selectedDayIso, setSelectedDayIso] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<main style={{ padding: 16, display: "grid", gap: 16 }}>
|
||||
<header>
|
||||
<h1 style={{ marginTop: 0 }}>{getAppName()}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href="/admin">Admin</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api/healthz">API health</a>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1fr",
|
||||
gap: 16,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<TimelineTree onSelectDay={setSelectedDayIso} />
|
||||
<MediaPanel selectedDayIso={selectedDayIso} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,13 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
outputFileTracingRoot: path.join(__dirname, "../../.."),
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@tline/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tline/config": "workspace:*",
|
||||
"@tline/db": "workspace:*",
|
||||
"@tline/minio": "workspace:*",
|
||||
"@tline/queue": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.899.0",
|
||||
"next": "15.5.3",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"types": [
|
||||
"bun-types"
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"allowJs": true,
|
||||
"incremental": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM oven/bun:1.3.3 AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace manifests (copy all workspace package.json files so Bun
|
||||
# can resolve workspace:* deps without mutating the lockfile).
|
||||
COPY package.json bun.lock tsconfig.base.json ./
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
COPY apps/worker/package.json ./apps/worker/package.json
|
||||
COPY packages/config/package.json ./packages/config/package.json
|
||||
COPY packages/db/package.json ./packages/db/package.json
|
||||
COPY packages/minio/package.json ./packages/minio/package.json
|
||||
COPY packages/queue/package.json ./packages/queue/package.json
|
||||
|
||||
RUN bun install --frozen-lockfile --production --ignore-scripts
|
||||
|
||||
FROM oven/bun:1.3.3 AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Media tooling for worker pipeline
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg libimage-exiftool-perl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/package.json ./package.json
|
||||
COPY --from=deps /app/bun.lock ./bun.lock
|
||||
|
||||
COPY apps/worker ./apps/worker
|
||||
COPY packages ./packages
|
||||
|
||||
CMD ["bun", "--cwd", "apps/worker", "run", "start"]
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@tline/worker",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tline/config": "workspace:*",
|
||||
"@tline/db": "workspace:*",
|
||||
"@tline/minio": "workspace:*",
|
||||
"@tline/queue": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.899.0",
|
||||
"bullmq": "^5.61.0",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
"start": "bun run src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getAppName } from "@tline/config";
|
||||
import { Worker, type Job } from "bullmq";
|
||||
|
||||
import { closeQueue, getQueueEnv, getQueueName, getRedis } from "@tline/queue";
|
||||
import { closeDb } from "@tline/db";
|
||||
|
||||
import {
|
||||
handleCopyToCanonical,
|
||||
handleProcessAsset,
|
||||
handleScanMinioPrefix
|
||||
} from "./jobs";
|
||||
|
||||
console.log(`[${getAppName()}] worker boot`);
|
||||
|
||||
const env = getQueueEnv();
|
||||
const queueName = getQueueName();
|
||||
|
||||
const connection = getRedis();
|
||||
|
||||
try {
|
||||
await connection.connect();
|
||||
} catch (err) {
|
||||
console.error(`[${getAppName()}] redis connect failed`, { err, redisUrl: env.REDIS_URL });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const worker = new Worker(
|
||||
queueName,
|
||||
async (job: Job) => {
|
||||
if (job.name === "scan_minio_prefix") return handleScanMinioPrefix(job.data);
|
||||
if (job.name === "process_asset") return handleProcessAsset(job.data);
|
||||
if (job.name === "copy_to_canonical") return handleCopyToCanonical(job.data);
|
||||
|
||||
throw new Error(`Unknown job: ${job.name}`);
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 1
|
||||
}
|
||||
);
|
||||
|
||||
worker.on("failed", (job: Job | undefined, err: Error) => {
|
||||
console.error(`[${getAppName()}] job failed`, { jobId: job?.id, name: job?.name, err });
|
||||
});
|
||||
|
||||
worker.on("completed", (job: Job) => {
|
||||
console.log(`[${getAppName()}] job completed`, { jobId: job.id, name: job.name });
|
||||
});
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[${getAppName()}] shutting down`, { signal });
|
||||
await Promise.allSettled([worker.close(), closeDb()]);
|
||||
await Promise.allSettled([closeQueue()]);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
@@ -0,0 +1,616 @@
|
||||
import { spawn } from "child_process";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { createWriteStream, createReadStream } from "fs";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import sharp from "sharp";
|
||||
|
||||
import {
|
||||
CopyObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand
|
||||
} from "@aws-sdk/client-s3";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { getMinioInternalClient } from "@tline/minio";
|
||||
import {
|
||||
copyToCanonicalPayloadSchema,
|
||||
enqueueCopyToCanonical,
|
||||
enqueueProcessAsset,
|
||||
processAssetPayloadSchema,
|
||||
scanMinioPrefixPayloadSchema,
|
||||
} from "@tline/queue";
|
||||
|
||||
const allowedScanPrefixes = ["originals/"] as const;
|
||||
|
||||
function assertAllowedScanPrefix(prefix: string) {
|
||||
if (allowedScanPrefixes.some((allowed) => prefix.startsWith(allowed))) return;
|
||||
throw new Error(`scan prefix not allowed: ${prefix}`);
|
||||
}
|
||||
|
||||
function getExtensionLower(key: string) {
|
||||
const dot = key.lastIndexOf(".");
|
||||
if (dot === -1) return "";
|
||||
return key.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function inferMedia(
|
||||
key: string,
|
||||
): { mediaType: "image" | "video"; mimeType: string } | null {
|
||||
const ext = getExtensionLower(key);
|
||||
|
||||
if (["jpg", "jpeg"].includes(ext))
|
||||
return { mediaType: "image", mimeType: "image/jpeg" };
|
||||
if (ext === "png") return { mediaType: "image", mimeType: "image/png" };
|
||||
if (ext === "gif") return { mediaType: "image", mimeType: "image/gif" };
|
||||
if (ext === "webp") return { mediaType: "image", mimeType: "image/webp" };
|
||||
if (ext === "heic") return { mediaType: "image", mimeType: "image/heic" };
|
||||
if (ext === "heif") return { mediaType: "image", mimeType: "image/heif" };
|
||||
|
||||
if (ext === "mov") return { mediaType: "video", mimeType: "video/quicktime" };
|
||||
if (ext === "mp4") return { mediaType: "video", mimeType: "video/mp4" };
|
||||
if (ext === "m4v") return { mediaType: "video", mimeType: "video/x-m4v" };
|
||||
if (ext === "mkv")
|
||||
return { mediaType: "video", mimeType: "video/x-matroska" };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listAllObjectKeys(input: { bucket: string; prefix: string }) {
|
||||
const s3 = getMinioInternalClient();
|
||||
|
||||
const keys: string[] = [];
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const res = await s3.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: input.bucket,
|
||||
Prefix: input.prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const obj of res.Contents ?? []) {
|
||||
if (!obj.Key) continue;
|
||||
keys.push(obj.Key);
|
||||
}
|
||||
|
||||
continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export async function handleScanMinioPrefix(raw: unknown) {
|
||||
const payload = scanMinioPrefixPayloadSchema.parse(raw);
|
||||
|
||||
assertAllowedScanPrefix(payload.prefix);
|
||||
|
||||
const keys = await listAllObjectKeys({
|
||||
bucket: payload.bucket,
|
||||
prefix: payload.prefix,
|
||||
});
|
||||
|
||||
const db = getDb();
|
||||
|
||||
let processed = 0;
|
||||
let skipped = 0;
|
||||
let enqueued = 0;
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.endsWith("/")) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const inferred = inferMedia(key);
|
||||
if (!inferred) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
}[]
|
||||
>`
|
||||
insert into assets (bucket, media_type, mime_type, source_key, active_key)
|
||||
values (${payload.bucket}, ${inferred.mediaType}, ${inferred.mimeType}, ${key}, ${key})
|
||||
on conflict (bucket, source_key)
|
||||
do update
|
||||
set media_type = excluded.media_type,
|
||||
mime_type = excluded.mime_type,
|
||||
active_key = excluded.active_key
|
||||
returning id, status
|
||||
`;
|
||||
|
||||
processed++;
|
||||
|
||||
const [asset] = rows;
|
||||
if (!asset) continue;
|
||||
|
||||
if (asset.status === "new" || asset.status === "failed") {
|
||||
await enqueueProcessAsset({ assetId: asset.id });
|
||||
enqueued++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
importId: payload.importId,
|
||||
bucket: payload.bucket,
|
||||
scannedPrefix: payload.prefix,
|
||||
found: keys.length,
|
||||
processed,
|
||||
skipped,
|
||||
enqueued,
|
||||
};
|
||||
}
|
||||
|
||||
function streamToFile(stream: Readable, filePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const writeStream = createWriteStream(filePath);
|
||||
stream.pipe(writeStream);
|
||||
writeStream.on("finish", resolve);
|
||||
writeStream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommand(cmd: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(cmd, args);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(`${cmd} failed with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadObject(input: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
filePath: string;
|
||||
contentType?: string;
|
||||
}): Promise<void> {
|
||||
const s3 = getMinioInternalClient();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.key,
|
||||
Body: createReadStream(input.filePath),
|
||||
ContentType: input.contentType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getObjectLastModified(input: { bucket: string; key: string }): Promise<Date | null> {
|
||||
const s3 = getMinioInternalClient();
|
||||
const res = await s3.send(new HeadObjectCommand({ Bucket: input.bucket, Key: input.key }));
|
||||
return res.LastModified ?? null;
|
||||
}
|
||||
|
||||
function parseExifDate(dateStr: string | undefined): Date | null {
|
||||
if (!dateStr) return null;
|
||||
const s = dateStr.trim();
|
||||
|
||||
// ExifTool commonly emits: "YYYY:MM:DD HH:MM:SS", sometimes with fractional seconds and/or tz.
|
||||
const m = s.match(
|
||||
/^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(?:\s*(Z|[+-]\d{2}:\d{2}))?$/,
|
||||
);
|
||||
|
||||
if (m) {
|
||||
const [, y, mo, d, hh, mm, ss, frac, tz] = m;
|
||||
// If tz missing, prefer deterministic UTC over server-local interpretation.
|
||||
const iso = `${y}-${mo}-${d}T${hh}:${mm}:${ss}${frac ?? ""}${tz ?? "Z"}`;
|
||||
const date = new Date(iso);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
const date = new Date(s);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function isPlausibleCaptureTs(date: Date) {
|
||||
const ts = date.getTime();
|
||||
if (!Number.isFinite(ts)) return false;
|
||||
const year = date.getUTCFullYear();
|
||||
// Guard against bogus container/default dates; allow up to 24h in future.
|
||||
return year >= 1971 && ts <= Date.now() + 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function inferExtFromKey(key: string): string {
|
||||
const ext = getExtensionLower(key);
|
||||
return ext || "bin";
|
||||
}
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function utcDateParts(date: Date) {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = date.getUTCMonth() + 1;
|
||||
const d = date.getUTCDate();
|
||||
return { y, m, d };
|
||||
}
|
||||
|
||||
export async function handleProcessAsset(raw: unknown) {
|
||||
const payload = processAssetPayloadSchema.parse(raw);
|
||||
const db = getDb();
|
||||
const s3 = getMinioInternalClient();
|
||||
|
||||
await db`
|
||||
update assets
|
||||
set status = 'processing', error_message = null
|
||||
where id = ${payload.assetId}
|
||||
and status in ('new', 'failed')
|
||||
`;
|
||||
|
||||
try {
|
||||
const [asset] = await db<
|
||||
{
|
||||
id: string;
|
||||
bucket: string;
|
||||
active_key: string;
|
||||
media_type: "image" | "video";
|
||||
mime_type: string;
|
||||
created_at: Date;
|
||||
}[]
|
||||
>`
|
||||
select id, bucket, active_key, media_type, mime_type, created_at
|
||||
from assets
|
||||
where id = ${payload.assetId}
|
||||
`;
|
||||
|
||||
if (!asset) {
|
||||
throw new Error(`Asset not found: ${payload.assetId}`);
|
||||
}
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "tline-process-"));
|
||||
|
||||
try {
|
||||
const containerExt = asset.mime_type.split("/")[1] ?? "bin";
|
||||
const inputPath = join(tempDir, `input.${containerExt}`);
|
||||
const getRes = await s3.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.bucket,
|
||||
Key: asset.active_key,
|
||||
}),
|
||||
);
|
||||
if (!getRes.Body) throw new Error("Empty response body from S3");
|
||||
await streamToFile(getRes.Body as Readable, inputPath);
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
capture_ts_utc: null,
|
||||
date_confidence: null,
|
||||
width: null,
|
||||
height: null,
|
||||
rotation: null,
|
||||
duration_seconds: null,
|
||||
thumb_small_key: null,
|
||||
thumb_med_key: null,
|
||||
poster_key: null,
|
||||
raw_tags_json: null
|
||||
};
|
||||
let rawTags: Record<string, unknown> = {};
|
||||
let captureTs: Date | null = null;
|
||||
let dateConfidence:
|
||||
| "camera"
|
||||
| "container"
|
||||
| "object_mtime"
|
||||
| "import_time"
|
||||
| null = null;
|
||||
|
||||
async function tryReadExifTags(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const exifOutput = await runCommand("exiftool", ["-j", inputPath]);
|
||||
const exifData = JSON.parse(exifOutput);
|
||||
if (Array.isArray(exifData) && exifData.length > 0) {
|
||||
const first = exifData[0];
|
||||
if (first && typeof first === "object") {
|
||||
return first as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { exiftool_error: message };
|
||||
}
|
||||
}
|
||||
|
||||
function maybeSetCaptureDateFromTags(tags: Record<string, unknown>) {
|
||||
if (captureTs) return;
|
||||
// ExifTool uses different fields across image/video vendors.
|
||||
const dateFields = [
|
||||
"DateTimeOriginal",
|
||||
"CreateDate",
|
||||
"ModifyDate",
|
||||
"MediaCreateDate",
|
||||
"TrackCreateDate",
|
||||
"CreationDate",
|
||||
"GPSDateTime",
|
||||
] as const;
|
||||
for (const field of dateFields) {
|
||||
const val = tags[field] as string | undefined;
|
||||
if (!val) continue;
|
||||
const parsed = parseExifDate(val);
|
||||
if (parsed && isPlausibleCaptureTs(parsed)) {
|
||||
captureTs = parsed;
|
||||
dateConfidence = "camera";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyObjectMtimeFallback() {
|
||||
if (captureTs) return;
|
||||
try {
|
||||
const mtime = await getObjectLastModified({
|
||||
bucket: asset.bucket,
|
||||
key: asset.active_key,
|
||||
});
|
||||
if (!mtime) return;
|
||||
if (!isPlausibleCaptureTs(mtime)) return;
|
||||
captureTs = mtime;
|
||||
dateConfidence = "object_mtime";
|
||||
rawTags = { ...rawTags, object_last_modified: mtime.toISOString() };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
rawTags = { ...rawTags, object_last_modified_error: message };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (asset.media_type === "image") {
|
||||
rawTags = await tryReadExifTags();
|
||||
maybeSetCaptureDateFromTags(rawTags);
|
||||
await applyObjectMtimeFallback();
|
||||
|
||||
|
||||
if (rawTags.ImageWidth !== undefined) updates.width = Number(rawTags.ImageWidth);
|
||||
if (rawTags.ImageHeight !== undefined) updates.height = Number(rawTags.ImageHeight);
|
||||
if (rawTags.Rotation !== undefined) updates.rotation = Number(rawTags.Rotation);
|
||||
|
||||
const imgMeta = await sharp(inputPath).metadata();
|
||||
if (updates.width === null && imgMeta.width) updates.width = imgMeta.width;
|
||||
if (updates.height === null && imgMeta.height) updates.height = imgMeta.height;
|
||||
|
||||
const thumb256Path = join(tempDir, "thumb_256.jpg");
|
||||
const thumb768Path = join(tempDir, "thumb_768.jpg");
|
||||
await sharp(inputPath)
|
||||
.rotate()
|
||||
.resize(256, 256, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumb256Path);
|
||||
await sharp(inputPath)
|
||||
.rotate()
|
||||
.resize(768, 768, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumb768Path);
|
||||
|
||||
const thumb256Key = `thumbs/${asset.id}/image_256.jpg`;
|
||||
const thumb768Key = `thumbs/${asset.id}/image_768.jpg`;
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: thumb256Key,
|
||||
filePath: thumb256Path,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: thumb768Key,
|
||||
filePath: thumb768Path,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
updates.thumb_small_key = thumb256Key;
|
||||
updates.thumb_med_key = thumb768Key;
|
||||
} else if (asset.media_type === "video") {
|
||||
rawTags = await tryReadExifTags();
|
||||
maybeSetCaptureDateFromTags(rawTags);
|
||||
|
||||
const ffprobeOutput = await runCommand("ffprobe", [
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height,duration",
|
||||
"-show_entries",
|
||||
"format_tags=creation_time",
|
||||
"-of",
|
||||
"json",
|
||||
inputPath
|
||||
]);
|
||||
const ffprobeData = JSON.parse(ffprobeOutput);
|
||||
|
||||
if (!captureTs && ffprobeData.format?.tags?.creation_time) {
|
||||
const ts = new Date(ffprobeData.format.tags.creation_time);
|
||||
if (!isNaN(ts.getTime()) && isPlausibleCaptureTs(ts)) {
|
||||
captureTs = ts;
|
||||
dateConfidence = "container";
|
||||
}
|
||||
}
|
||||
|
||||
await applyObjectMtimeFallback();
|
||||
|
||||
if (ffprobeData.streams?.[0]) {
|
||||
const stream = ffprobeData.streams[0];
|
||||
if (stream.width) updates.width = Number(stream.width);
|
||||
if (stream.height) updates.height = Number(stream.height);
|
||||
if (stream.duration)
|
||||
updates.duration_seconds = Math.round(Number(stream.duration));
|
||||
}
|
||||
|
||||
rawTags = { ...rawTags, ffprobe: ffprobeData };
|
||||
|
||||
const posterPath = join(tempDir, "poster_256.jpg");
|
||||
await runCommand("ffmpeg", [
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vf",
|
||||
"scale=256:256:force_original_aspect_ratio=decrease",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-q:v",
|
||||
"2",
|
||||
"-y",
|
||||
posterPath
|
||||
]);
|
||||
const posterKey = `thumbs/${asset.id}/poster_256.jpg`;
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: posterKey,
|
||||
filePath: posterPath,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
updates.poster_key = posterKey;
|
||||
}
|
||||
|
||||
if (asset.media_type === "video" && typeof updates.poster_key !== "string") {
|
||||
throw new Error("poster generation did not produce output");
|
||||
}
|
||||
|
||||
if (
|
||||
asset.media_type === "image" &&
|
||||
(typeof updates.thumb_small_key !== "string" || typeof updates.thumb_med_key !== "string")
|
||||
) {
|
||||
throw new Error("thumb generation did not produce output");
|
||||
}
|
||||
|
||||
if (!captureTs) {
|
||||
captureTs = new Date(asset.created_at);
|
||||
dateConfidence = "import_time";
|
||||
rawTags = {
|
||||
...rawTags,
|
||||
capture_date_fallback: "import_time",
|
||||
};
|
||||
}
|
||||
|
||||
updates.capture_ts_utc = captureTs;
|
||||
updates.date_confidence = dateConfidence;
|
||||
updates.raw_tags_json = rawTags;
|
||||
|
||||
|
||||
await db`
|
||||
update assets
|
||||
set ${db(
|
||||
updates,
|
||||
"capture_ts_utc",
|
||||
"date_confidence",
|
||||
"width",
|
||||
"height",
|
||||
"rotation",
|
||||
"duration_seconds",
|
||||
"thumb_small_key",
|
||||
"thumb_med_key",
|
||||
"poster_key",
|
||||
"raw_tags_json"
|
||||
)}, status = 'ready', error_message = null
|
||||
where id = ${asset.id}
|
||||
`;
|
||||
|
||||
// Only uploads (staging/*) are copied into canonical by default.
|
||||
if (asset.active_key.startsWith("staging/")) {
|
||||
await enqueueCopyToCanonical({ assetId: asset.id });
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "unknown_error";
|
||||
|
||||
await db`
|
||||
update assets
|
||||
set status = 'failed', error_message = ${message}
|
||||
where id = ${payload.assetId}
|
||||
`;
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCopyToCanonical(raw: unknown) {
|
||||
const payload = copyToCanonicalPayloadSchema.parse(raw);
|
||||
|
||||
const db = getDb();
|
||||
const s3 = getMinioInternalClient();
|
||||
|
||||
const [asset] = await db<
|
||||
{
|
||||
id: string;
|
||||
bucket: string;
|
||||
source_key: string;
|
||||
active_key: string;
|
||||
canonical_key: string | null;
|
||||
capture_ts_utc: Date | null;
|
||||
}[]
|
||||
>`
|
||||
select id, bucket, source_key, active_key, canonical_key, capture_ts_utc
|
||||
from assets
|
||||
where id = ${payload.assetId}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!asset) throw new Error(`Asset not found: ${payload.assetId}`);
|
||||
|
||||
// Canonical layout is date-based; if we don't have a date yet, do nothing.
|
||||
// This job can be retried later after metadata extraction improves.
|
||||
if (!asset.capture_ts_utc) {
|
||||
return { ok: true, assetId: asset.id, skipped: "missing_capture_ts" };
|
||||
}
|
||||
|
||||
// Never copy external archive originals by default.
|
||||
if (asset.source_key.startsWith("originals/")) {
|
||||
return { ok: true, assetId: asset.id, skipped: "external_archive" };
|
||||
}
|
||||
|
||||
const ext = inferExtFromKey(asset.source_key);
|
||||
const { y, m, d } = utcDateParts(new Date(asset.capture_ts_utc));
|
||||
const canonicalKey = `canonical/originals/${y}/${pad2(m)}/${pad2(d)}/${asset.id}.${ext}`;
|
||||
|
||||
// Idempotency: if already canonicalized, don't redo work.
|
||||
if (asset.canonical_key === canonicalKey && asset.active_key === canonicalKey) {
|
||||
return { ok: true, assetId: asset.id, canonicalKey, already: true };
|
||||
}
|
||||
|
||||
await s3.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: asset.bucket,
|
||||
Key: canonicalKey,
|
||||
CopySource: `${asset.bucket}/${asset.active_key}`,
|
||||
MetadataDirective: "COPY",
|
||||
}),
|
||||
);
|
||||
|
||||
await db`
|
||||
update assets
|
||||
set canonical_key = ${canonicalKey}, active_key = ${canonicalKey}
|
||||
where id = ${asset.id}
|
||||
`;
|
||||
|
||||
return { ok: true, assetId: asset.id, canonicalKey };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "../../packages/*/src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user