Initial commit

This commit is contained in:
OpenCode Test
2025-12-24 10:50:10 -08:00
commit e1a64aa092
70 changed files with 5827 additions and 0 deletions
+38
View File
@@ -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"]
+8
View File
@@ -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>
);
}
+94
View File
@@ -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"
}
});
}
+114
View File
@@ -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,
});
}
+3
View File
@@ -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 });
}
+37
View File
@@ -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);
}
+137
View 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,
});
}
+291
View File
@@ -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>
);
}
+368
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+6
View File
@@ -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.
+13
View File
@@ -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;
+21
View File
@@ -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"
}
}
+26
View File
@@ -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"
]
}
+35
View File
@@ -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"]
+19
View File
@@ -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"
}
}
+58
View File
@@ -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"));
+616
View File
@@ -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 };
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun-types"]
},
"include": ["src/**/*.ts", "../../packages/*/src/**/*.ts"]
}