feat: implement all future features

Admin auth, media variants, video transcoding, tags/albums,
metadata overrides, GPS/map, dedupe/moments, endpoint selection,
lifecycle policies, and CI builds.
This commit is contained in:
William Valentin
2026-02-06 14:00:58 -08:00
71 changed files with 4150 additions and 347 deletions
+152
View File
@@ -0,0 +1,152 @@
name: Build and Push Multi-Arch Images
on:
push:
branches: [main, master, 'feature/*']
tags: ['v*']
pull_request:
branches: [main, master]
env:
REGISTRY: gitea-gitea-http.taildb3494.ts.net
jobs:
test-and-typecheck:
name: Test and Typecheck
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run TypeScript typecheck
run: bun run typecheck
- name: Run tests
run: bash run_tests.sh
build-web:
name: Build Web Image (Multi-Arch)
runs-on: ubuntu-latest
needs: test-and-typecheck
if: github.event_name != 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/porthole-web
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=,suffix=,format=short
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-worker:
name: Build Worker Image (Multi-Arch)
runs-on: ubuntu-latest
needs: test-and-typecheck
if: github.event_name != 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/porthole-worker
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=,suffix=,format=short
- name: Build and push worker image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/worker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-pr:
name: Build Images (PR Only - No Push)
runs-on: ubuntu-latest
needs: test-and-typecheck
if: github.event_name == 'pull_request'
strategy:
matrix:
app: [web, worker]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image (no push)
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/${{ matrix.app }}/Dockerfile
platforms: linux/amd64,linux/arm64
push: false
cache-from: type=gha
+17
View File
@@ -1,5 +1,7 @@
# porthole
[![Build Status](/repos/will/porthole/badge.svg?branch=main)](/repos/will/porthole/actions)
Porthole: timeline media library (Next.js web + worker), backed by Postgres/Redis/MinIO.
## How to try it
@@ -84,6 +86,21 @@ spec:
This repo is a Bun monorepo, but container builds use Docker Buildx.
### CI/CD (Automated)
The repository includes a Gitea Actions workflow (`.gitea/workflows/build-images.yml`) that automatically:
- Runs `bun run typecheck` on every push and PR
- Runs `bash run_tests.sh` (Go tests) to keep the repo green
- Builds and pushes multi-arch images (`linux/amd64`, `linux/arm64`) for `apps/web` and `apps/worker`
- Pushes to `gitea-gitea-http.taildb3494.ts.net/will/porthole-web` and `.../porthole-worker`
Images are tagged with:
- Branch name (e.g., `main`, `feature/my-branch`)
- Git SHA (short format)
- Semantic version (when tags like `v1.2.3` are pushed)
### Manual Build
- Assumptions:
- You have an **in-cluster registry** reachable over **insecure HTTP** (example: `registry.lan:5000`).
- Your Docker daemon is configured to allow that registry as an insecure registry.
+248 -3
View File
@@ -1,8 +1,253 @@
"use client";
import { useEffect, useMemo, useState } from "react";
const ADMIN_TOKEN_KEY = "porthole_admin_token";
type Tag = {
id: string;
name: string;
created_at: string;
};
type Album = {
id: string;
name: string;
created_at: string;
};
export default function AdminPage() {
const [token, setToken] = useState("");
const [tokenInput, setTokenInput] = useState("");
const [tokenMessage, setTokenMessage] = useState<string | null>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [tagsError, setTagsError] = useState<string | null>(null);
const [tagsLoading, setTagsLoading] = useState(false);
const [newTag, setNewTag] = useState("");
const [albums, setAlbums] = useState<Album[]>([]);
const [albumsError, setAlbumsError] = useState<string | null>(null);
const [albumsLoading, setAlbumsLoading] = useState(false);
const [newAlbum, setNewAlbum] = useState("");
useEffect(() => {
if (typeof window === "undefined") return;
const stored = sessionStorage.getItem(ADMIN_TOKEN_KEY) ?? "";
setToken(stored);
setTokenInput(stored);
}, []);
const adminHeaders = useMemo(() => {
if (!token) return null;
return { "X-Porthole-Admin-Token": token };
}, [token]);
async function loadTags() {
if (!adminHeaders) {
setTagsError("Set admin token first.");
return;
}
setTagsLoading(true);
setTagsError(null);
try {
const res = await fetch("/api/tags", {
headers: adminHeaders,
cache: "no-store",
});
if (!res.ok) throw new Error(`tags_fetch_failed:${res.status}`);
const json = (await res.json()) as Tag[];
setTags(json);
} catch (err) {
setTagsError(err instanceof Error ? err.message : String(err));
} finally {
setTagsLoading(false);
}
}
async function loadAlbums() {
if (!adminHeaders) {
setAlbumsError("Set admin token first.");
return;
}
setAlbumsLoading(true);
setAlbumsError(null);
try {
const res = await fetch("/api/albums", {
headers: adminHeaders,
cache: "no-store",
});
if (!res.ok) throw new Error(`albums_fetch_failed:${res.status}`);
const json = (await res.json()) as Album[];
setAlbums(json);
} catch (err) {
setAlbumsError(err instanceof Error ? err.message : String(err));
} finally {
setAlbumsLoading(false);
}
}
async function handleSaveToken(event: React.FormEvent) {
event.preventDefault();
if (typeof window === "undefined") return;
const trimmed = tokenInput.trim();
if (trimmed) {
sessionStorage.setItem(ADMIN_TOKEN_KEY, trimmed);
setToken(trimmed);
setTokenMessage("Token saved for this session.");
} else {
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
setToken("");
setTokenMessage("Token cleared.");
}
}
async function handleCreateTag(event: React.FormEvent) {
event.preventDefault();
if (!adminHeaders) {
setTagsError("Set admin token first.");
return;
}
if (!newTag.trim()) {
setTagsError("Tag name is required.");
return;
}
try {
setTagsError(null);
const res = await fetch("/api/tags", {
method: "POST",
headers: { ...adminHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ name: newTag.trim() }),
});
if (!res.ok) throw new Error(`tag_create_failed:${res.status}`);
setNewTag("");
await loadTags();
} catch (err) {
setTagsError(err instanceof Error ? err.message : String(err));
}
}
async function handleCreateAlbum(event: React.FormEvent) {
event.preventDefault();
if (!adminHeaders) {
setAlbumsError("Set admin token first.");
return;
}
if (!newAlbum.trim()) {
setAlbumsError("Album name is required.");
return;
}
try {
setAlbumsError(null);
const res = await fetch("/api/albums", {
method: "POST",
headers: { ...adminHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ name: newAlbum.trim() }),
});
if (!res.ok) throw new Error(`album_create_failed:${res.status}`);
setNewAlbum("");
await loadAlbums();
} catch (err) {
setAlbumsError(err instanceof Error ? err.message : String(err));
}
}
return (
<main style={{ padding: 16 }}>
<h1 style={{ marginTop: 0 }}>Admin</h1>
<p>Upload + scan tools will live here.</p>
<main style={{ padding: 16, display: "grid", gap: 20, maxWidth: 720 }}>
<header>
<h1 style={{ marginTop: 0 }}>Admin</h1>
<p style={{ color: "#555" }}>
Manage tags and albums. Admin token is stored in sessionStorage.
</p>
</header>
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>Admin Token</h2>
<form onSubmit={handleSaveToken} style={{ display: "grid", gap: 8 }}>
<input
type="password"
placeholder="X-Porthole-Admin-Token"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
style={{ padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
/>
<div style={{ display: "flex", gap: 8 }}>
<button type="submit">Save token</button>
<button
type="button"
onClick={() => {
setTokenInput("");
setToken("");
if (typeof window !== "undefined") {
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
}
setTokenMessage("Token cleared.");
}}
>
Clear
</button>
</div>
{tokenMessage ? (
<div style={{ fontSize: 12, color: "#444" }}>{tokenMessage}</div>
) : null}
</form>
</section>
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>Tags</h2>
<form onSubmit={handleCreateTag} style={{ display: "flex", gap: 8 }}>
<input
type="text"
placeholder="New tag name"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
/>
<button type="submit">Create</button>
<button type="button" onClick={loadTags} disabled={tagsLoading}>
{tagsLoading ? "Loading..." : "Refresh"}
</button>
</form>
{tagsError ? (
<div style={{ color: "#b00", marginTop: 8 }}>{tagsError}</div>
) : null}
<ul style={{ marginTop: 12, paddingLeft: 16 }}>
{tags.length === 0 ? (
<li style={{ color: "#666" }}>No tags yet.</li>
) : (
tags.map((tag) => <li key={tag.id}>{tag.name}</li>)
)}
</ul>
</section>
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>Albums</h2>
<form onSubmit={handleCreateAlbum} style={{ display: "flex", gap: 8 }}>
<input
type="text"
placeholder="New album name"
value={newAlbum}
onChange={(e) => setNewAlbum(e.target.value)}
style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
/>
<button type="submit">Create</button>
<button type="button" onClick={loadAlbums} disabled={albumsLoading}>
{albumsLoading ? "Loading..." : "Refresh"}
</button>
</form>
{albumsError ? (
<div style={{ color: "#b00", marginTop: 8 }}>{albumsError}</div>
) : null}
<ul style={{ marginTop: 12, paddingLeft: 16 }}>
{albums.length === 0 ? (
<li style={{ color: "#666" }}>No albums yet.</li>
) : (
albums.map((album) => <li key={album.id}>{album.name}</li>)
)}
</ul>
</section>
</main>
);
}
@@ -0,0 +1,57 @@
import { z } from "zod";
import {
getAdminOk,
handleAddAlbumAsset,
handleRemoveAlbumAsset,
} from "../../handlers";
export const runtime = "nodejs";
const paramsSchema = z.object({
id: z.string().uuid(),
});
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 bodyJson = await request.json().catch(() => ({}));
const res = await handleAddAlbumAsset({
adminOk: getAdminOk(request.headers),
params: paramsParsed.data,
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
export async function DELETE(
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 bodyJson = await request.json().catch(() => ({}));
const res = await handleRemoveAlbumAsset({
adminOk: getAdminOk(request.headers),
params: paramsParsed.data,
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
+184
View File
@@ -0,0 +1,184 @@
import { getAdminToken, isAdminRequest } from "@tline/config";
import { getDb } from "@tline/db";
import { z } from "zod";
const ADMIN_HEADER = "X-Porthole-Admin-Token";
const createAlbumBodySchema = z
.object({
name: z.string().min(1),
})
.strict();
const albumParamsSchema = z.object({
id: z.string().uuid(),
});
const albumAssetBodySchema = z
.object({
assetId: z.string().uuid(),
ord: z.coerce.number().int().optional(),
})
.strict();
type DbLike = ReturnType<typeof getDb>;
export function getAdminOk(headers: Headers) {
const headerToken = headers.get(ADMIN_HEADER);
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
}
export async function handleListAlbums(input: {
adminOk: boolean;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const db = (input.db ?? getDb()) as DbLike;
const rows = await db<
{
id: string;
name: string;
created_at: string;
}[]
>`
select id, name, created_at
from albums
order by created_at desc
`;
return { status: 200, body: rows };
}
export async function handleCreateAlbum(input: {
adminOk: boolean;
body: unknown;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const bodyParsed = createAlbumBodySchema.safeParse(input.body ?? {});
if (!bodyParsed.success) {
return {
status: 400,
body: { error: "invalid_body", issues: bodyParsed.error.issues },
};
}
const body = bodyParsed.data;
const db = (input.db ?? getDb()) as DbLike;
const rows = await db<
{
id: string;
name: string;
created_at: string;
}[]
>`
insert into albums (name)
values (${body.name})
returning id, name, created_at
`;
const created = rows[0];
if (!created) {
return { status: 500, body: { error: "insert_failed" } };
}
const payload = JSON.stringify({ name: created.name });
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'create', 'album', ${created.id}, ${payload}::jsonb)
`;
return { status: 200, body: created };
}
export async function handleAddAlbumAsset(input: {
adminOk: boolean;
params: { id: string };
body: unknown;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const paramsParsed = albumParamsSchema.safeParse(input.params);
if (!paramsParsed.success) {
return {
status: 400,
body: { error: "invalid_params", issues: paramsParsed.error.issues },
};
}
const body = albumAssetBodySchema.parse(input.body ?? {});
const db = (input.db ?? getDb()) as DbLike;
const rows = await db<
{
album_id: string;
asset_id: string;
ord: number | null;
}[]
>`
insert into album_assets (album_id, asset_id, ord)
values (${paramsParsed.data.id}, ${body.assetId}, ${body.ord ?? null})
on conflict (album_id, asset_id)
do update set ord = excluded.ord
returning album_id, asset_id, ord
`;
const created = rows[0];
if (!created) {
return { status: 500, body: { error: "insert_failed" } };
}
const payload = JSON.stringify({
asset_id: created.asset_id,
ord: created.ord,
});
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'add_asset', 'album', ${created.album_id}, ${payload}::jsonb)
`;
return { status: 200, body: created };
}
export async function handleRemoveAlbumAsset(input: {
adminOk: boolean;
params: { id: string };
body: unknown;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const paramsParsed = albumParamsSchema.safeParse(input.params);
if (!paramsParsed.success) {
return {
status: 400,
body: { error: "invalid_params", issues: paramsParsed.error.issues },
};
}
const body = albumAssetBodySchema.parse(input.body ?? {});
const db = (input.db ?? getDb()) as DbLike;
await db`
delete from album_assets
where album_id = ${paramsParsed.data.id}
and asset_id = ${body.assetId}
`;
const payload = JSON.stringify({ asset_id: body.assetId });
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'remove_asset', 'album', ${paramsParsed.data.id}, ${payload}::jsonb)
`;
return { status: 200, body: { ok: true } };
}
+17
View File
@@ -0,0 +1,17 @@
import { getAdminOk, handleCreateAlbum, handleListAlbums } from "./handlers";
export const runtime = "nodejs";
export async function GET(request: Request): Promise<Response> {
const res = await handleListAlbums({ adminOk: getAdminOk(request.headers) });
return Response.json(res.body, { status: res.status });
}
export async function POST(request: Request): Promise<Response> {
const bodyJson = await request.json().catch(() => ({}));
const res = await handleCreateAlbum({
adminOk: getAdminOk(request.headers),
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
@@ -0,0 +1,56 @@
import { z } from "zod";
import { getDb } from "@tline/db";
const paramsSchema = z.object({ id: z.string().uuid() });
type DbLike = ReturnType<typeof getDb>;
export async function handleGetDupes(input: {
params: { id: string };
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
const paramsParsed = paramsSchema.safeParse(input.params);
if (!paramsParsed.success) {
return {
status: 400,
body: { error: "invalid_params", issues: paramsParsed.error.issues },
};
}
const db = (input.db ?? getDb()) as DbLike;
const hashRows = await db<
{
bucket: string;
sha256: string;
}[]
>`
select bucket, sha256
from asset_hashes
where asset_id = ${paramsParsed.data.id}
limit 1
`;
const hash = hashRows[0];
if (!hash) {
return { status: 200, body: { items: [] } };
}
const dupes = await db<
{
id: string;
media_type: "image" | "video";
status: "new" | "processing" | "ready" | "failed";
}[]
>`
select a.id, a.media_type, a.status
from assets a
join asset_hashes h on h.asset_id = a.id
where h.bucket = ${hash.bucket}
and h.sha256 = ${hash.sha256}
and a.id <> ${paramsParsed.data.id}
order by a.id asc
`;
return { status: 200, body: { items: dupes } };
}
@@ -0,0 +1,12 @@
import { handleGetDupes } from "./handlers";
export const runtime = "nodejs";
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const result = await handleGetDupes({ params: rawParams });
return Response.json(result.body, { status: result.status });
}
@@ -0,0 +1,133 @@
import { getAdminToken, isAdminRequest } from "@tline/config";
import { getDb } from "@tline/db";
import { z } from "zod";
const ADMIN_HEADER = "X-Porthole-Admin-Token";
const paramsSchema = z.object({
id: z.string().uuid(),
});
const bodySchema = z
.object({
captureTsUtcOverride: z.string().datetime().nullable().optional(),
captureOffsetMinutesOverride: z.number().int().nullable().optional(),
})
.strict();
type DbLike = ReturnType<typeof getDb>;
export function getAdminOk(headers: Headers) {
const headerToken = headers.get(ADMIN_HEADER);
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
}
export async function handleSetCaptureOverride(input: {
adminOk: boolean;
params: { id: string };
body: unknown;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const paramsParsed = paramsSchema.safeParse(input.params);
if (!paramsParsed.success) {
return {
status: 400,
body: { error: "invalid_params", issues: paramsParsed.error.issues },
};
}
const bodyParsed = bodySchema.safeParse(input.body ?? {});
if (!bodyParsed.success) {
return {
status: 400,
body: { error: "invalid_body", issues: bodyParsed.error.issues },
};
}
const data = bodyParsed.data;
const hasCaptureTs = "captureTsUtcOverride" in data;
const hasCaptureOffset = "captureOffsetMinutesOverride" in data;
if (!hasCaptureTs && !hasCaptureOffset) {
return { status: 400, body: { error: "invalid_body" } };
}
const db = (input.db ?? getDb()) as DbLike;
const captureTs = hasCaptureTs
? data.captureTsUtcOverride
? new Date(data.captureTsUtcOverride)
: null
: null;
const captureOffset = hasCaptureOffset
? data.captureOffsetMinutesOverride ?? null
: null;
const rows = await db<
{
asset_id: string;
capture_ts_utc_override: string | null;
capture_offset_minutes_override: number | null;
created_at: string;
}[]
>`
insert into asset_overrides (
asset_id,
capture_ts_utc_override,
capture_offset_minutes_override
)
values (
${paramsParsed.data.id},
${captureTs},
${captureOffset}
)
on conflict (asset_id)
do update set
capture_ts_utc_override = case
when ${hasCaptureTs} then excluded.capture_ts_utc_override
else asset_overrides.capture_ts_utc_override
end,
capture_offset_minutes_override = case
when ${hasCaptureOffset} then excluded.capture_offset_minutes_override
else asset_overrides.capture_offset_minutes_override
end
returning asset_id, capture_ts_utc_override, capture_offset_minutes_override, created_at
`;
const created = rows[0];
if (!created) {
return { status: 500, body: { error: "insert_failed" } };
}
const assetRows = await db<
{
capture_ts_utc: string | null;
}[]
>`
select capture_ts_utc
from assets
where id = ${created.asset_id}
limit 1
`;
const baseCaptureTs = assetRows[0]?.capture_ts_utc ?? null;
const payload = JSON.stringify({
capture_ts_utc_override: created.capture_ts_utc_override,
capture_offset_minutes_override: created.capture_offset_minutes_override,
});
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'override_capture_ts', 'asset', ${created.asset_id}, ${payload}::jsonb)
`;
return {
status: 200,
body: {
...created,
base_capture_ts_utc: baseCaptureTs,
},
};
}
@@ -0,0 +1,31 @@
import { z } from "zod";
import { getAdminOk, handleSetCaptureOverride } from "./handlers";
export const runtime = "nodejs";
const paramsSchema = z.object({
id: z.string().uuid(),
});
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 bodyJson = await request.json().catch(() => ({}));
const res = await handleSetCaptureOverride({
adminOk: getAdminOk(request.headers),
params: paramsParsed.data,
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
@@ -0,0 +1,78 @@
import { getAdminToken, isAdminRequest } from "@tline/config";
import { getDb } from "@tline/db";
import { z } from "zod";
export const runtime = "nodejs";
const ADMIN_HEADER = "X-Porthole-Admin-Token";
const paramsSchema = z.object({
id: z.string().uuid(),
});
const bodySchema = z
.object({
tagId: z.string().uuid(),
})
.strict();
function getAdminOk(headers: Headers) {
const headerToken = headers.get(ADMIN_HEADER);
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
}
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
if (!getAdminOk(request.headers)) {
return Response.json({ error: "admin_required" }, { status: 401 });
}
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 bodyJson = await request.json().catch(() => ({}));
const bodyParsed = bodySchema.safeParse(bodyJson);
if (!bodyParsed.success) {
return Response.json(
{ error: "invalid_body", issues: bodyParsed.error.issues },
{ status: 400 },
);
}
const db = getDb();
const rows = await db<
{
asset_id: string;
tag_id: string;
}[]
>`
insert into asset_tags (asset_id, tag_id)
values (${paramsParsed.data.id}, ${bodyParsed.data.tagId})
on conflict (asset_id, tag_id)
do nothing
returning asset_id, tag_id
`;
const created =
rows[0] ??
({ asset_id: paramsParsed.data.id, tag_id: bodyParsed.data.tagId } as const);
const payload = JSON.stringify({
asset_id: created.asset_id,
tag_id: created.tag_id,
});
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'add_tag', 'asset', ${created.asset_id}, ${payload}::jsonb)
`;
return Response.json(created, { status: 200 });
}
+126 -17
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { getDb } from "@tline/db";
import { presignGetObjectUrl } from "@tline/minio";
import { pickLegacyKeyForRequest, pickVariantKey } from "./variant";
export const runtime = "nodejs";
@@ -9,7 +10,16 @@ const paramsSchema = z.object({
id: z.string().uuid()
});
const variantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]);
const legacyVariantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]);
const kindSchema = z.enum(["original", "thumb", "poster", "video_mp4"]);
const sizeSchema = z.coerce.number().int().positive();
const videoMp4DefaultSize = 720;
const legacyVariantMap = {
original: { kind: "original" as const },
thumb_small: { kind: "thumb" as const, size: 256 },
thumb_med: { kind: "thumb" as const, size: 768 },
poster: { kind: "poster" as const, size: 256 },
};
export async function GET(
request: Request,
@@ -26,14 +36,71 @@ export async function GET(
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 kindParam = url.searchParams.get("kind");
const sizeParam = url.searchParams.get("size");
const legacyVariantParam = url.searchParams.get("variant");
const endpointParam = url.searchParams.get("endpoint");
let requestedKind: z.infer<typeof kindSchema> = "original";
let requestedSize: number | null = null;
let legacyVariant: z.infer<typeof legacyVariantSchema> | null = null;
let endpointOverride: "lan" | "tailnet" | undefined;
if (kindParam) {
const kindParsed = kindSchema.safeParse(kindParam);
if (!kindParsed.success) {
return Response.json(
{ error: "invalid_query", issues: kindParsed.error.issues },
{ status: 400 },
);
}
requestedKind = kindParsed.data;
if (requestedKind !== "original") {
if (requestedKind === "video_mp4" && !sizeParam) {
requestedSize = videoMp4DefaultSize;
} else {
const sizeParsed = sizeSchema.safeParse(sizeParam);
if (!sizeParsed.success) {
return Response.json(
{ error: "invalid_query", issues: sizeParsed.error.issues },
{ status: 400 },
);
}
requestedSize = sizeParsed.data;
}
}
} else if (legacyVariantParam) {
const legacyParsed = legacyVariantSchema.safeParse(legacyVariantParam);
if (!legacyParsed.success) {
return Response.json(
{ error: "invalid_query", issues: legacyParsed.error.issues },
{ status: 400 },
);
}
legacyVariant = legacyParsed.data;
const mapped = legacyVariantMap[legacyVariant];
requestedKind = mapped.kind;
requestedSize = "size" in mapped ? mapped.size : null;
}
if (endpointParam) {
if (endpointParam !== "lan" && endpointParam !== "tailnet") {
return Response.json(
{
error: "invalid_query",
issues: [
{
code: "custom",
message: "endpoint must be lan or tailnet",
path: ["endpoint"],
},
],
},
{ status: 400 },
);
}
endpointOverride = endpointParam;
}
const variant = variantParsed.data;
const db = getDb();
const rows = await db<
@@ -52,38 +119,80 @@ export async function GET(
limit 1
`;
const variants = await db<
{
kind: string;
size: number;
key: string;
mime_type: string;
width: number | null;
height: number | null;
}[]
>`
select kind, size, key, mime_type, width, height
from asset_variants
where asset_id = ${params.id}
`;
const asset = rows[0];
if (!asset) {
return Response.json({ error: "not_found" }, { status: 404 });
}
const legacyKey = legacyVariant
? pickLegacyKeyForRequest(
{ asset },
{ kind: requestedKind, size: requestedSize ?? 0 },
)
: requestedSize !== null
? pickLegacyKeyForRequest(
{ asset },
{ kind: requestedKind, size: requestedSize },
)
: null;
const key =
variant === "original"
requestedKind === "original"
? asset.active_key
: variant === "thumb_small"
? asset.thumb_small_key
: variant === "thumb_med"
? asset.thumb_med_key
: asset.poster_key;
: requestedSize !== null
? pickVariantKey(
{ variants },
{ kind: requestedKind, size: requestedSize },
) ?? legacyKey
: null;
if (!key) {
return Response.json(
{ error: "variant_not_available", variant },
{ error: "variant_not_available", kind: requestedKind, size: requestedSize },
{ status: 404 }
);
}
// Hint the browser; especially helpful for Range playback.
const responseContentType = variant === "original" ? asset.mime_type : "image/jpeg";
const matchedVariant =
requestedKind === "original" || requestedSize === null
? null
: variants.find(
(item) => item.kind === requestedKind && item.size === requestedSize,
) ?? null;
const responseContentType =
requestedKind === "original"
? asset.mime_type
: matchedVariant?.mime_type ??
(requestedKind === "video_mp4" ? "video/mp4" : "image/jpeg");
const responseContentDisposition =
variant === "original" && asset.mime_type.startsWith("video/") ? "inline" : undefined;
(requestedKind === "original" && asset.mime_type.startsWith("video/")) ||
requestedKind === "video_mp4"
? "inline"
: undefined;
const signed = await presignGetObjectUrl({
bucket: asset.bucket,
key,
responseContentType,
responseContentDisposition,
endpoint: endpointOverride,
});
return Response.json(signed, {
@@ -0,0 +1,31 @@
export function pickVariantKey(
input: { variants: Array<{ kind: string; size: number; key: string }> },
req: { kind: string; size: number },
) {
const v = input.variants.find(
(x) => x.kind === req.kind && x.size === req.size,
);
return v?.key ?? null;
}
export function pickLegacyKeyForRequest(
input: {
asset: {
thumb_small_key: string | null;
thumb_med_key: string | null;
poster_key: string | null;
};
},
req: { kind: string; size: number },
) {
if (req.kind === "thumb" && req.size === 256) {
return input.asset.thumb_small_key ?? null;
}
if (req.kind === "thumb" && req.size === 768) {
return input.asset.thumb_med_key ?? null;
}
if (req.kind === "poster" && req.size === 256) {
return input.asset.poster_key ?? null;
}
return null;
}
@@ -0,0 +1,43 @@
import { z } from "zod";
import { getDb } from "@tline/db";
import { shapeVariants } from "./shape";
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 db = getDb();
const rows = await db<
{
kind: string;
size: number;
key: string;
mime_type: string;
width: number | null;
height: number | null;
}[]
>`
select kind, size, key, mime_type, width, height
from asset_variants
where asset_id = ${paramsParsed.data.id}
`;
return Response.json(shapeVariants(rows));
}
@@ -0,0 +1,19 @@
type VariantRow = {
kind: string;
size: number;
key: string;
};
type VariantShape = {
kind: string;
size: number;
key: string;
};
export function shapeVariants(rows: VariantRow[]): VariantShape[] {
return rows.map((row) => ({
kind: row.kind,
size: row.size,
key: row.key,
}));
}
+26 -24
View File
@@ -68,35 +68,37 @@ export async function GET(request: Request): Promise<Response> {
}[]
>`
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
a.id,
a.bucket,
a.media_type,
a.mime_type,
a.active_key,
coalesce(o.capture_ts_utc_override, a.capture_ts_utc) as capture_ts_utc,
a.date_confidence,
a.width,
a.height,
a.rotation,
a.duration_seconds,
a.thumb_small_key,
a.thumb_med_key,
a.poster_key,
a.status,
a.error_message
from assets a
left join asset_overrides o
on o.asset_id = a.id
where true
and 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 coalesce(o.capture_ts_utc_override, a.capture_ts_utc) is not null
and (${start}::timestamptz is null or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) >= ${start}::timestamptz)
and (${end}::timestamptz is null or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) < ${end}::timestamptz)
and (${query.mediaType ?? null}::media_type is null or a.media_type = ${query.mediaType ?? null}::media_type)
and (${query.status ?? null}::asset_status is null or a.status = ${query.status ?? null}::asset_status)
and (
${cursorId}::uuid is null
or ${cursorTs}::timestamptz is null
or (capture_ts_utc, id) > (${cursorTs}::timestamptz, ${cursorId}::uuid)
or (coalesce(o.capture_ts_utc_override, a.capture_ts_utc), a.id) > (${cursorTs}::timestamptz, ${cursorId}::uuid)
)
order by capture_ts_utc asc nulls last, id asc
order by coalesce(o.capture_ts_utc_override, a.capture_ts_utc) asc nulls last, a.id asc
limit ${query.limit}
`;
+29
View File
@@ -0,0 +1,29 @@
import { getDb } from "@tline/db";
import { shapeGeoRows } from "./shape";
export const runtime = "nodejs";
export async function GET(): Promise<Response> {
const db = getDb();
const rows = await db<
{
id: string;
gps_lat: number | null;
gps_lon: number | null;
}[]
>`
select
a.id,
a.gps_lat,
a.gps_lon
from assets a
where a.gps_lat is not null
and a.gps_lon is not null
order by a.capture_ts_utc asc nulls last, a.id asc
limit 1000
`;
return Response.json(shapeGeoRows(rows));
}
+19
View File
@@ -0,0 +1,19 @@
type GeoRow = {
id: string;
gps_lat: number | null;
gps_lon: number | null;
};
type GeoPoint = {
id: string;
gps_lat: number | null;
gps_lon: number | null;
};
export function shapeGeoRows(rows: GeoRow[]): GeoPoint[] {
return rows.map((row) => ({
id: row.id,
gps_lat: row.gps_lat,
gps_lon: row.gps_lon,
}));
}
@@ -1,66 +1,17 @@
import { z } from "zod";
import { getDb } from "@tline/db";
import { getMinioBucket } from "@tline/minio";
import { enqueueScanMinioPrefix } from "@tline/queue";
import { getAdminOk, handleScanMinioImport } from "../handlers";
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,
const res = await handleScanMinioImport({
adminOk: getAdminOk(request.headers),
params: rawParams,
body: bodyJson,
});
await db`
update imports
set status = 'queued'
where id = ${imp.id}
`;
return Response.json({ ok: true, importId: imp.id, bucket, prefix: body.prefix });
return Response.json(res.body, { status: res.status });
}
+7 -99
View File
@@ -1,108 +1,16 @@
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";
import { getAdminOk, handleUploadImport } from "../handlers";
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 });
const res = await handleUploadImport({
adminOk: getAdminOk(request.headers),
params: rawParams,
request,
});
return Response.json(res.body, { status: res.status });
}
+220
View File
@@ -0,0 +1,220 @@
import { getAdminToken, isAdminRequest } from "@tline/config";
import { getDb } from "@tline/db";
import { z } from "zod";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
const ADMIN_HEADER = "X-Porthole-Admin-Token";
const createImportBodySchema = z
.object({
type: z.enum(["upload", "minio_scan"]).default("upload"),
})
.strict();
const uploadParamsSchema = z.object({ id: z.string().uuid() });
const scanParamsSchema = z.object({ id: z.string().uuid() });
const scanBodySchema = z
.object({
bucket: z.string().min(1).optional(),
prefix: z.string().min(1).default("originals/"),
})
.strict();
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 function getAdminOk(headers: Headers) {
const headerToken = headers.get(ADMIN_HEADER);
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
}
export async function handleCreateImport(input: {
adminOk: boolean;
body: unknown;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const body = createImportBodySchema.parse(input.body ?? {});
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 { status: 500, body: { error: "insert_failed" } };
}
return { status: 200, body: created };
}
export async function handleUploadImport(input: {
adminOk: boolean;
params: { id: string };
request: Request;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const paramsParsed = uploadParamsSchema.safeParse(input.params);
if (!paramsParsed.success) {
return {
status: 400,
body: { error: "invalid_params", issues: paramsParsed.error.issues },
};
}
const params = paramsParsed.data;
const { randomUUID } = await import("crypto");
const { Readable } = await import("stream");
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
const { getMinioBucket, getMinioInternalClient } = await import("@tline/minio");
const { enqueueProcessAsset } = await import("@tline/queue");
const contentType = input.request.headers.get("content-type") ?? "application/octet-stream";
const mediaType = inferMediaTypeFromContentType(contentType);
if (!mediaType) {
return { status: 400, body: { error: "unsupported_content_type", contentType } };
}
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 { status: 404, body: { error: "import_not_found" } };
}
if (!input.request.body) {
return { status: 400, body: { error: "missing_body" } };
}
const s3 = getMinioInternalClient();
const bodyStream = Readable.fromWeb(input.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 { status: 500, body: { error: "asset_insert_failed" } };
}
await enqueueProcessAsset({ assetId: asset.id });
return {
status: 200,
body: { ok: true, importId: imp.id, assetId: asset.id, bucket, key },
};
}
export async function handleScanMinioImport(input: {
adminOk: boolean;
params: { id: string };
body: unknown;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const paramsParsed = scanParamsSchema.safeParse(input.params);
if (!paramsParsed.success) {
return {
status: 400,
body: { error: "invalid_params", issues: paramsParsed.error.issues },
};
}
const params = paramsParsed.data;
const body = scanBodySchema.parse(input.body ?? {});
const { getMinioBucket } = await import("@tline/minio");
const { enqueueScanMinioPrefix } = await import("@tline/queue");
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 { status: 404, body: { error: "not_found" } };
}
await enqueueScanMinioPrefix({
importId: imp.id,
bucket,
prefix: body.prefix,
});
await db`
update imports
set status = 'queued'
where id = ${imp.id}
`;
return {
status: 200,
body: { ok: true, importId: imp.id, bucket, prefix: body.prefix },
};
}
+6 -31
View File
@@ -1,37 +1,12 @@
import { z } from "zod";
import { getDb } from "@tline/db";
import { getAdminOk, handleCreateImport } from "./handlers";
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);
const res = await handleCreateImport({
adminOk: getAdminOk(request.headers),
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
+69
View File
@@ -0,0 +1,69 @@
import { z } from "zod";
import { getDb } from "@tline/db";
import { clusterMoments } from "../../lib/moments";
export const runtime = "nodejs";
const querySchema = z
.object({
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
includeFailed: z.coerce.number().int().optional(),
limit: z.coerce.number().int().positive().max(2000).default(1000),
})
.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,
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 includeFailed = query.includeFailed === 1;
const db = getDb();
const rows = await db<
{
id: string;
capture_ts_utc: string | null;
}[]
>`
select id, capture_ts_utc
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 (${includeFailed}::boolean is true or status <> 'failed')
order by capture_ts_utc asc, id asc
limit ${query.limit}
`;
const clusters = clusterMoments(
rows
.filter((row) => Boolean(row.capture_ts_utc))
.map((row) => ({
id: row.id,
capture_ts_utc: row.capture_ts_utc as string,
})),
);
return Response.json({
start: start ? start.toISOString() : null,
end: end ? end.toISOString() : null,
clusters,
});
}
+87
View File
@@ -0,0 +1,87 @@
import { getAdminToken, isAdminRequest } from "@tline/config";
import { getDb } from "@tline/db";
import { z } from "zod";
const ADMIN_HEADER = "X-Porthole-Admin-Token";
const createTagBodySchema = z
.object({
name: z.string().min(1),
})
.strict();
type DbLike = ReturnType<typeof getDb>;
export function getAdminOk(headers: Headers) {
const headerToken = headers.get(ADMIN_HEADER);
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
}
export async function handleListTags(input: {
adminOk: boolean;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const db = (input.db ?? getDb()) as DbLike;
const rows = await db<
{
id: string;
name: string;
created_at: string;
}[]
>`
select id, name, created_at
from tags
order by created_at desc
`;
return { status: 200, body: rows };
}
export async function handleCreateTag(input: {
adminOk: boolean;
body: unknown;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const bodyParsed = createTagBodySchema.safeParse(input.body ?? {});
if (!bodyParsed.success) {
return {
status: 400,
body: { error: "invalid_body", issues: bodyParsed.error.issues },
};
}
const body = bodyParsed.data;
const db = (input.db ?? getDb()) as DbLike;
const rows = await db<
{
id: string;
name: string;
created_at: string;
}[]
>`
insert into tags (name)
values (${body.name})
returning id, name, created_at
`;
const created = rows[0];
if (!created) {
return { status: 500, body: { error: "insert_failed" } };
}
const payload = JSON.stringify({ name: created.name });
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'create', 'tag', ${created.id}, ${payload}::jsonb)
`;
return { status: 200, body: created };
}
+17
View File
@@ -0,0 +1,17 @@
import { getAdminOk, handleCreateTag, handleListTags } from "./handlers";
export const runtime = "nodejs";
export async function GET(request: Request): Promise<Response> {
const res = await handleListTags({ adminOk: getAdminOk(request.headers) });
return Response.json(res.body, { status: res.status });
}
export async function POST(request: Request): Promise<Response> {
const bodyJson = await request.json().catch(() => ({}));
const res = await handleCreateTag({
adminOk: getAdminOk(request.headers),
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
+25 -17
View File
@@ -18,7 +18,7 @@ const querySchema = z
type Granularity = z.infer<typeof querySchema>["granularity"];
function sqlGroupExpr(granularity: Granularity, alias: string) {
const col = `${alias}.capture_ts_utc`;
const col = `${alias}.effective_capture_ts_utc`;
if (granularity === "year") return `date_trunc('year', ${col})`;
if (granularity === "month") return `date_trunc('month', ${col})`;
return `date_trunc('day', ${col})`;
@@ -71,23 +71,31 @@ export async function GET(request: Request): Promise<Response> {
>`
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)
a.id,
a.bucket,
a.media_type,
a.status,
coalesce(o.capture_ts_utc_override, a.capture_ts_utc) as effective_capture_ts_utc,
a.active_key,
a.thumb_small_key,
a.thumb_med_key,
a.poster_key
from assets a
left join asset_overrides o
on o.asset_id = a.id
where coalesce(o.capture_ts_utc_override, a.capture_ts_utc) is not null
and (
${start}::timestamptz is null
or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) >= ${start}::timestamptz
)
and (
${end}::timestamptz is null
or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) < ${end}::timestamptz
)
and (${query.mediaType ?? null}::media_type is null or a.media_type = ${query.mediaType ?? null}::media_type)
and (
${query.includeFailed}::boolean = true
or status <> 'failed'
or a.status <> 'failed'
)
),
grouped as (
@@ -120,7 +128,7 @@ export async function GET(request: Request): Promise<Response> {
where f.bucket = g.bucket
and ${db.unsafe(groupExprF)} = g.group_ts
and f.status = 'ready'
order by f.capture_ts_utc asc
order by f.effective_capture_ts_utc asc
limit 1
) s on true
order by g.group_ts desc
+423 -8
View File
@@ -2,6 +2,8 @@
import { useEffect, useMemo, useState } from "react";
import { pickVideoPlaybackVariant } from "../lib/playback";
type Asset = {
id: string;
media_type: "image" | "video";
@@ -23,7 +25,17 @@ type SignedUrlResponse = {
expiresSeconds: number;
};
type OverrideResponse = {
capture_ts_utc_override: string | null;
base_capture_ts_utc: string | null;
};
type PreviewUrlState = Record<string, string | undefined>;
type VideoPlaybackVariant = { kind: "original" } | { kind: "video_mp4"; size: number };
type VariantsResponse = Array<{ kind: string; size: number; key: string }>;
type Tag = { id: string; name: string };
type Album = { id: string; name: string };
type DupesResponse = { items: Array<{ id: string }> };
function startOfDayUtc(iso: string) {
const d = new Date(iso);
@@ -45,7 +57,7 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
const [viewer, setViewer] = useState<{
asset: Asset;
url: string;
variant: "original" | "thumb_med" | "poster";
variant: "original" | "thumb_med" | "poster" | "video_mp4";
} | null>(null);
const [viewerError, setViewerError] = useState<string | null>(null);
@@ -53,6 +65,18 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
posterUrl: string | null;
} | null>(null);
const [retryKey, setRetryKey] = useState(0);
const [tags, setTags] = useState<Tag[]>([]);
const [albums, setAlbums] = useState<Album[]>([]);
const [tagId, setTagId] = useState("");
const [albumId, setAlbumId] = useState("");
const [adminError, setAdminError] = useState<string | null>(null);
const [adminBusy, setAdminBusy] = useState(false);
const [overrideInput, setOverrideInput] = useState("");
const [overrideError, setOverrideError] = useState<string | null>(null);
const [overrideBusy, setOverrideBusy] = useState(false);
const [baseCaptureTs, setBaseCaptureTs] = useState<string | null>(null);
const [dupes, setDupes] = useState<Array<{ id: string }> | null>(null);
const [dupesError, setDupesError] = useState<string | null>(null);
const range = useMemo(() => {
if (!props.selectedDayIso) return null;
@@ -103,16 +127,65 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
async function loadSignedUrl(
assetId: string,
variant: "original" | "thumb_small" | "thumb_med" | "poster",
variant:
| "original"
| "thumb_small"
| "thumb_med"
| "poster"
| "video_mp4_720",
sizeOverride?: number,
) {
const res = await fetch(`/api/assets/${assetId}/url?variant=${variant}`, {
cache: "no-store",
});
const url =
variant === "video_mp4_720"
? `/api/assets/${assetId}/url?kind=video_mp4&size=${sizeOverride ?? 720}`
: `/api/assets/${assetId}/url?variant=${variant}`;
const res = await fetch(url, { 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 loadVideoPlaybackUrl(
assetId: string,
): Promise<{ url: string; variant: VideoPlaybackVariant }> {
try {
const res = await fetch(`/api/assets/${assetId}/variants`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`variants_fetch_failed:${res.status}`);
const variants = (await res.json()) as VariantsResponse;
const picked = pickVideoPlaybackVariant({
originalMimeType: null,
variants: variants
.filter((variant) => variant.kind === "video_mp4")
.map((variant) => ({
kind: "video_mp4",
size: variant.size,
key: variant.key,
})),
});
if (picked?.kind === "video_mp4") {
const url = await loadSignedUrl(assetId, "video_mp4_720", picked.size);
return { url, variant: { kind: "video_mp4", size: picked.size } };
}
} catch {
// fall through to original
}
const url = await loadSignedUrl(assetId, "original");
return { url, variant: { kind: "original" } };
}
async function loadDupes(assetId: string) {
setDupesError(null);
setDupes(null);
const res = await fetch(`/api/assets/${assetId}/dupes`, { cache: "no-store" });
if (!res.ok) throw new Error(`dupes_fetch_failed:${res.status}`);
const json = (await res.json()) as DupesResponse;
setDupes(json.items);
}
async function openViewer(asset: Asset) {
if (asset.status === "failed") {
setViewerError(`${asset.id}: ${asset.error_message ?? "asset_failed"}`);
@@ -123,9 +196,207 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
setViewerError(null);
setVideoFallback(null);
const variant: "original" | "thumb_med" | "poster" = "original";
const url = await loadSignedUrl(asset.id, variant);
setViewer({ asset, url, variant });
try {
if (asset.media_type === "video") {
const playback = await loadVideoPlaybackUrl(asset.id);
const variantLabel =
playback.variant.kind === "video_mp4"
? "video_mp4"
: playback.variant.kind;
setViewer({ asset, url: playback.url, variant: variantLabel });
setBaseCaptureTs(asset.capture_ts_utc);
setOverrideInput(asset.capture_ts_utc ?? "");
setOverrideError(null);
void loadAdminLists();
void loadDupes(asset.id).catch((err) => {
setDupesError(err instanceof Error ? err.message : String(err));
setDupes([]);
});
return;
}
const variant: "original" | "thumb_med" | "poster" = "original";
const url = await loadSignedUrl(asset.id, variant);
setViewer({ asset, url, variant });
setBaseCaptureTs(asset.capture_ts_utc);
setOverrideInput(asset.capture_ts_utc ?? "");
setOverrideError(null);
void loadAdminLists();
void loadDupes(asset.id).catch((err) => {
setDupesError(err instanceof Error ? err.message : String(err));
setDupes([]);
});
} catch (err) {
setViewer(null);
setViewerError(
err instanceof Error ? err.message : "viewer_open_failed",
);
}
}
async function handleOverrideCaptureTs() {
if (!viewer) return;
setOverrideError(null);
setOverrideBusy(true);
try {
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
if (!token) throw new Error("missing_admin_token");
const trimmed = overrideInput.trim();
if (!trimmed) throw new Error("enter_iso_timestamp");
const res = await fetch(
`/api/assets/${viewer.asset.id}/override-capture-ts`,
{
method: "POST",
headers: {
"X-Porthole-Admin-Token": token,
"Content-Type": "application/json",
},
body: JSON.stringify({ captureTsUtcOverride: trimmed }),
},
);
if (!res.ok) throw new Error(`override_failed:${res.status}`);
const json = (await res.json()) as OverrideResponse;
setViewer((prev) =>
prev
? {
...prev,
asset: {
...prev.asset,
capture_ts_utc:
json.capture_ts_utc_override ?? json.base_capture_ts_utc,
},
}
: prev,
);
setBaseCaptureTs(json.base_capture_ts_utc ?? null);
setOverrideError("Override saved.");
} catch (err) {
setOverrideError(err instanceof Error ? err.message : String(err));
} finally {
setOverrideBusy(false);
}
}
async function handleClearOverride() {
if (!viewer) return;
setOverrideError(null);
setOverrideBusy(true);
try {
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
if (!token) throw new Error("missing_admin_token");
const res = await fetch(
`/api/assets/${viewer.asset.id}/override-capture-ts`,
{
method: "POST",
headers: {
"X-Porthole-Admin-Token": token,
"Content-Type": "application/json",
},
body: JSON.stringify({ captureTsUtcOverride: null }),
},
);
if (!res.ok) throw new Error(`override_clear_failed:${res.status}`);
const json = (await res.json()) as OverrideResponse;
setViewer((prev) =>
prev
? {
...prev,
asset: {
...prev.asset,
capture_ts_utc:
json.capture_ts_utc_override ?? json.base_capture_ts_utc,
},
}
: prev,
);
setBaseCaptureTs(json.base_capture_ts_utc ?? null);
setOverrideInput(json.base_capture_ts_utc ?? "");
setOverrideError("Override cleared.");
} catch (err) {
setOverrideError(err instanceof Error ? err.message : String(err));
} finally {
setOverrideBusy(false);
}
}
async function loadAdminLists() {
setAdminError(null);
setAdminBusy(true);
try {
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
if (!token) {
setAdminError("Set admin token on /admin first.");
return;
}
const headers = { "X-Porthole-Admin-Token": token };
const [tagsRes, albumsRes] = await Promise.all([
fetch("/api/tags", { headers, cache: "no-store" }),
fetch("/api/albums", { headers, cache: "no-store" }),
]);
if (!tagsRes.ok) throw new Error(`tags_fetch_failed:${tagsRes.status}`);
if (!albumsRes.ok)
throw new Error(`albums_fetch_failed:${albumsRes.status}`);
const tagsJson = (await tagsRes.json()) as Tag[];
const albumsJson = (await albumsRes.json()) as Album[];
setTags(tagsJson);
setAlbums(albumsJson);
} catch (err) {
setAdminError(err instanceof Error ? err.message : String(err));
} finally {
setAdminBusy(false);
}
}
async function handleAssignTag() {
if (!viewer) return;
setAdminError(null);
setAdminBusy(true);
try {
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
if (!token) throw new Error("missing_admin_token");
if (!tagId) throw new Error("select_tag");
const res = await fetch(`/api/assets/${viewer.asset.id}/tags`, {
method: "POST",
headers: {
"X-Porthole-Admin-Token": token,
"Content-Type": "application/json",
},
body: JSON.stringify({ tagId }),
});
if (!res.ok) throw new Error(`tag_assign_failed:${res.status}`);
setAdminError("Tag assigned.");
} catch (err) {
setAdminError(err instanceof Error ? err.message : String(err));
} finally {
setAdminBusy(false);
}
}
async function handleAddToAlbum() {
if (!viewer) return;
setAdminError(null);
setAdminBusy(true);
try {
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
if (!token) throw new Error("missing_admin_token");
if (!albumId) throw new Error("select_album");
const res = await fetch(`/api/albums/${albumId}/assets`, {
method: "POST",
headers: {
"X-Porthole-Admin-Token": token,
"Content-Type": "application/json",
},
body: JSON.stringify({ assetId: viewer.asset.id }),
});
if (!res.ok) throw new Error(`album_add_failed:${res.status}`);
setAdminError("Added to album.");
} catch (err) {
setAdminError(err instanceof Error ? err.message : String(err));
} finally {
setAdminBusy(false);
}
}
return (
@@ -377,6 +648,150 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
<div style={{ color: "#666", fontSize: 12 }}>
{viewer.asset.id}
</div>
<div
style={{
borderTop: "1px solid #eee",
paddingTop: 12,
display: "grid",
gap: 6,
}}
>
<strong style={{ fontSize: 13 }}>Duplicates</strong>
{dupesError ? (
<div style={{ color: "#b00", fontSize: 12 }}>
{dupesError}
</div>
) : null}
{dupes === null ? (
<div style={{ color: "#666", fontSize: 12 }}>
Loading...
</div>
) : dupes.length === 0 ? (
<div style={{ color: "#666", fontSize: 12 }}>
No duplicates.
</div>
) : (
<div
style={{
display: "grid",
gap: 4,
fontSize: 12,
color: "#444",
}}
>
<div>{dupes.length} duplicate(s)</div>
{dupes.map((dupe) => (
<div key={dupe.id}>{dupe.id.slice(0, 8)}</div>
))}
</div>
)}
</div>
<div
style={{
borderTop: "1px solid #eee",
paddingTop: 12,
display: "grid",
gap: 8,
}}
>
<strong style={{ fontSize: 13 }}>Capture time override</strong>
<div style={{ fontSize: 12, color: "#555" }}>
Effective: {viewer.asset.capture_ts_utc ?? "(unset)"}
</div>
<div style={{ fontSize: 12, color: "#555" }}>
Base: {baseCaptureTs ?? "(unknown)"}
</div>
<div style={{ display: "grid", gap: 6 }}>
<label style={{ fontSize: 12, color: "#555" }}>
ISO timestamp
</label>
<input
type="text"
placeholder="2026-02-01T12:34:56.000Z"
value={overrideInput}
onChange={(e) => setOverrideInput(e.target.value)}
style={{ padding: 6, borderRadius: 6, border: "1px solid #ccc" }}
disabled={overrideBusy}
/>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={handleOverrideCaptureTs}>
Save override
</button>
<button type="button" onClick={handleClearOverride}>
Clear override
</button>
</div>
{overrideError ? (
<div style={{ fontSize: 12, color: "#b00" }}>
{overrideError}
</div>
) : null}
</div>
</div>
<div
style={{
borderTop: "1px solid #eee",
paddingTop: 12,
display: "grid",
gap: 8,
}}
>
<strong style={{ fontSize: 13 }}>Tags & Albums</strong>
{adminError ? (
<div style={{ color: "#b00", fontSize: 12 }}>
{adminError}
</div>
) : null}
<div style={{ display: "grid", gap: 6 }}>
<label style={{ fontSize: 12, color: "#555" }}>
Assign tag
</label>
<div style={{ display: "flex", gap: 8 }}>
<select
value={tagId}
onChange={(e) => setTagId(e.target.value)}
style={{ flex: 1, padding: 6 }}
disabled={adminBusy}
>
<option value="">Select tag</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</select>
<button type="button" onClick={handleAssignTag}>
Assign
</button>
</div>
</div>
<div style={{ display: "grid", gap: 6 }}>
<label style={{ fontSize: 12, color: "#555" }}>
Add to album
</label>
<div style={{ display: "flex", gap: 8 }}>
<select
value={albumId}
onChange={(e) => setAlbumId(e.target.value)}
style={{ flex: 1, padding: 6 }}
disabled={adminBusy}
>
<option value="">Select album</option>
{albums.map((album) => (
<option key={album.id} value={album.id}>
{album.name}
</option>
))}
</select>
<button type="button" onClick={handleAddToAlbum}>
Add
</button>
</div>
</div>
</div>
</>
) : (
<div style={{ color: "#b00" }}>
+62
View File
@@ -27,6 +27,17 @@ type ApiTreeResponse = {
nodes: ApiTreeRow[];
};
type MomentCluster = {
day: string;
count: number;
};
type MomentsResponse = {
start: string | null;
end: string | null;
clusters: MomentCluster[];
};
type Orientation = "vertical" | "horizontal";
type ExpandedState = Record<string, boolean>;
@@ -147,6 +158,9 @@ export function TimelineTree(props: {
const [expanded, setExpanded] = useState<ExpandedState>({});
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [showMoments, setShowMoments] = useState(false);
const [moments, setMoments] = useState<MomentsResponse | null>(null);
const [momentsError, setMomentsError] = useState<string | null>(null);
// simple pan/zoom via viewBox
const svgRef = useRef<SVGSVGElement | null>(null);
@@ -182,6 +196,38 @@ export function TimelineTree(props: {
};
}, []);
useEffect(() => {
if (!showMoments || !rows) return;
let cancelled = false;
async function loadMoments() {
try {
setMomentsError(null);
if (!rows || rows.length === 0) return;
const start = rows[0]?.group_ts ?? null;
const last = rows[rows.length - 1]?.group_ts ?? null;
const end = last
? new Date(new Date(last).getTime() + 24 * 60 * 60 * 1000).toISOString()
: null;
const params = new URLSearchParams();
if (start) params.set("start", start);
if (end) params.set("end", end);
params.set("includeFailed", "1");
const res = await fetch(`/api/moments?${params.toString()}`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`moments_fetch_failed:${res.status}`);
const json = (await res.json()) as MomentsResponse;
if (!cancelled) setMoments(json);
} catch (e) {
if (!cancelled) setMomentsError(e instanceof Error ? e.message : String(e));
}
}
void loadMoments();
return () => {
cancelled = true;
};
}, [showMoments, rows]);
const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]);
const visible = useMemo(
() => gatherVisible(roots, expanded),
@@ -315,12 +361,18 @@ export function TimelineTree(props: {
>
Reset view
</button>
<button type="button" onClick={() => setShowMoments((v) => !v)}>
{showMoments ? "Hide moments" : "Show moments"}
</button>
{rows ? (
<span style={{ color: "#666" }}>{rows.length} day nodes</span>
) : null}
</div>
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
{momentsError ? (
<div style={{ color: "#b00" }}>Moments error: {momentsError}</div>
) : null}
{!rows && !error ? (
<div
style={{
@@ -390,6 +442,15 @@ export function TimelineTree(props: {
const isDay = node.id.startsWith("d:");
const clickCursor = hasChildren || isDay ? "pointer" : "default";
const dayKey = node.label;
const dayMoments = showMoments
? moments?.clusters.filter((c) => c.day === dayKey) ?? []
: [];
const momentsCount = dayMoments.reduce(
(sum, c) => sum + c.count,
0,
);
return (
<g
key={node.id}
@@ -404,6 +465,7 @@ export function TimelineTree(props: {
<text x={20} y={5} fontSize={14} fill="#111">
{node.label} ({node.countReady}/{node.countTotal})
{hasChildren ? (isExpanded ? " ▼" : " ▶") : ""}
{showMoments && isDay ? ` · ${momentsCount} moment assets` : ""}
</text>
</g>
);
+1
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { getAppName } from "@tline/config";
import "leaflet/dist/leaflet.css";
export const metadata = {
title: getAppName()
+84
View File
@@ -0,0 +1,84 @@
export type MomentAsset = {
id: string;
capture_ts_utc: string;
};
export type MomentCluster = {
day: string;
start: string;
end: string;
count: number;
assets: MomentAsset[];
};
const MOMENT_WINDOW_MINUTES = 30;
const MOMENT_WINDOW_MS = MOMENT_WINDOW_MINUTES * 60 * 1000;
function dayKeyFromIso(iso: string) {
const d = new Date(iso);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
export function clusterMoments(input: MomentAsset[]): MomentCluster[] {
const byDay = new Map<string, MomentAsset[]>();
for (const asset of input) {
if (!asset.capture_ts_utc) continue;
const key = dayKeyFromIso(asset.capture_ts_utc);
const list = byDay.get(key);
if (list) list.push(asset);
else byDay.set(key, [asset]);
}
const clusters: MomentCluster[] = [];
for (const [day, assets] of byDay) {
const sorted = [...assets].sort((a, b) =>
a.capture_ts_utc.localeCompare(b.capture_ts_utc),
);
let current: MomentAsset[] = [];
let lastTs: number | null = null;
for (const asset of sorted) {
const ts = new Date(asset.capture_ts_utc).getTime();
if (!Number.isFinite(ts)) continue;
if (lastTs === null || ts - lastTs <= MOMENT_WINDOW_MS) {
current.push(asset);
} else {
const start = current[0]?.capture_ts_utc;
const end = current[current.length - 1]?.capture_ts_utc;
if (start && end) {
clusters.push({
day,
start,
end,
count: current.length,
assets: current,
});
}
current = [asset];
}
lastTs = ts;
}
if (current.length) {
const start = current[0].capture_ts_utc;
const end = current[current.length - 1].capture_ts_utc;
clusters.push({
day,
start,
end,
count: current.length,
assets: current,
});
}
}
return clusters;
}
+22
View File
@@ -0,0 +1,22 @@
type Variant = {
kind: "video_mp4";
size: number;
key: string;
};
type PlaybackInput = {
originalMimeType: string | null | undefined;
variants: Variant[];
};
export function pickVideoPlaybackVariant(
input: PlaybackInput,
): { kind: "video_mp4"; size: number } | null {
const mp4Variant = input.variants.find(
(variant) => variant.kind === "video_mp4" && variant.size === 720,
);
if (mp4Variant) {
return { kind: "video_mp4", size: mp4Variant.size };
}
return null;
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import L from "leaflet";
import { useEffect, useRef, useState } from "react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
type GeoPoint = {
id: string;
gps_lat: number | null;
gps_lon: number | null;
};
function MapContent({ points, error }: { points: GeoPoint[]; error: string | null }) {
const map = useMap();
const markersRef = useRef<any[]>([]);
useEffect(() => {
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
if (points.length === 0) return;
points.forEach((point) => {
if (point.gps_lat === null || point.gps_lon === null) return;
const marker = L.marker([point.gps_lat, point.gps_lon]);
marker.addTo(map);
markersRef.current.push(marker);
});
if (points.length > 0) {
const group = L.featureGroup(markersRef.current);
map.fitBounds(group.getBounds().pad(0.1));
}
}, [points, map]);
return null;
}
export default function MapPage() {
const [points, setPoints] = useState<GeoPoint[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/geo")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch geo points");
return res.json();
})
.then((data) => {
setPoints(data);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Unknown error");
})
.finally(() => {
setLoading(false);
});
}, []);
return (
<main style={{ padding: 16, display: "grid", gap: 16, height: "calc(100vh - 32px)" }}>
<header>
<h1 style={{ marginTop: 0 }}>Map</h1>
</header>
{loading ? (
<div style={{ textAlign: "center", padding: 40 }}>
Loading map...
</div>
) : error ? (
<div style={{ textAlign: "center", padding: 40, color: "#b00" }}>
Error: {error}
</div>
) : points.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#666" }}>
No GPS points available
</div>
) : (
<div style={{ flex: 1, minHeight: 0 }}>
<MapContainer
style={{ height: "100%", width: "100%" }}
boundsOptions={{ padding: [50, 50] }}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapContent points={points} error={error} />
</MapContainer>
</div>
)}
</main>
);
}
+3
View File
@@ -16,6 +16,9 @@ export default function HomePage() {
<header>
<h1 style={{ marginTop: 0 }}>{getAppName()}</h1>
<ul>
<li>
<Link href="/map">Map</Link>
</li>
<li>
<Link href="/admin">Admin</Link>
</li>
@@ -0,0 +1,30 @@
import { test, expect } from "bun:test";
test("imports POST rejects when missing admin token", async () => {
const { handleCreateImport } = await import("../../app/api/imports/handlers");
const res = await handleCreateImport({ adminOk: false, body: {} });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("imports upload rejects when missing admin token", async () => {
const { handleUploadImport } = await import("../../app/api/imports/handlers");
const res = await handleUploadImport({
adminOk: false,
params: { id: "00000000-0000-0000-0000-000000000000" },
request: new Request("http://localhost/upload"),
});
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("imports scan rejects when missing admin token", async () => {
const { handleScanMinioImport } = await import("../../app/api/imports/handlers");
const res = await handleScanMinioImport({
adminOk: false,
params: { id: "00000000-0000-0000-0000-000000000000" },
body: {},
});
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
@@ -0,0 +1,161 @@
import { test, expect } from "bun:test";
function createMockDb(responses: Array<unknown>) {
const calls: Array<{ sql: string; values: unknown[] }> = [];
const db = async <T>(strings: TemplateStringsArray, ...values: unknown[]) => {
calls.push({ sql: strings.join(""), values });
const next = responses.shift();
return next as T;
};
return { db, calls };
}
test("albums POST rejects when missing admin token", async () => {
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
const res = await handleCreateAlbum({ adminOk: false, body: {} });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("albums GET rejects when missing admin token", async () => {
const { handleListAlbums } = await import("../../app/api/albums/handlers");
const res = await handleListAlbums({ adminOk: false });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("album add asset rejects when missing admin token", async () => {
const { handleAddAlbumAsset } = await import(
"../../app/api/albums/handlers"
);
const res = await handleAddAlbumAsset({
adminOk: false,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { assetId: "00000000-0000-4000-8000-000000000000" },
});
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("album remove asset rejects when missing admin token", async () => {
const { handleRemoveAlbumAsset } = await import(
"../../app/api/albums/handlers"
);
const res = await handleRemoveAlbumAsset({
adminOk: false,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { assetId: "00000000-0000-4000-8000-000000000000" },
});
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("albums GET returns rows", async () => {
const { handleListAlbums } = await import("../../app/api/albums/handlers");
const { db } = createMockDb([
[
{
id: "00000000-0000-4000-8000-000000000010",
name: "Summer",
created_at: "2026-02-01T00:00:00.000Z",
},
],
]);
const res = await handleListAlbums({ adminOk: true, db: db as never });
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
id: "00000000-0000-4000-8000-000000000010",
name: "Summer",
created_at: "2026-02-01T00:00:00.000Z",
},
]);
});
test("albums POST inserts and writes audit log", async () => {
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
const { db, calls } = createMockDb([
[
{
id: "00000000-0000-4000-8000-000000000020",
name: "Trips",
created_at: "2026-02-01T00:00:00.000Z",
},
],
[],
]);
const res = await handleCreateAlbum({
adminOk: true,
body: { name: "Trips" },
db: db as never,
});
expect(res.status).toBe(200);
expect(res.body).toEqual({
id: "00000000-0000-4000-8000-000000000020",
name: "Trips",
created_at: "2026-02-01T00:00:00.000Z",
});
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
true,
);
});
test("albums POST rejects invalid body", async () => {
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
const res = await handleCreateAlbum({ adminOk: true, body: { name: "" } });
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true);
});
test("album add asset inserts and writes audit log", async () => {
const { handleAddAlbumAsset } = await import(
"../../app/api/albums/handlers"
);
const { db, calls } = createMockDb([
[
{
album_id: "00000000-0000-4000-8000-000000000030",
asset_id: "00000000-0000-4000-8000-000000000031",
ord: 2,
},
],
[],
]);
const res = await handleAddAlbumAsset({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000030" },
body: { assetId: "00000000-0000-4000-8000-000000000031", ord: 2 },
db: db as never,
});
expect(res.status).toBe(200);
expect(res.body).toEqual({
album_id: "00000000-0000-4000-8000-000000000030",
asset_id: "00000000-0000-4000-8000-000000000031",
ord: 2,
});
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
true,
);
});
test("album remove asset deletes and writes audit log", async () => {
const { handleRemoveAlbumAsset } = await import(
"../../app/api/albums/handlers"
);
const { db, calls } = createMockDb([[], [], []]);
const res = await handleRemoveAlbumAsset({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000040" },
body: { assetId: "00000000-0000-4000-8000-000000000041" },
db: db as never,
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(calls.some((call) => call.sql.includes("delete from album_assets"))).toBe(
true,
);
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
true,
);
});
@@ -0,0 +1,180 @@
import { test, expect } from "bun:test";
import type { getDb } from "@tline/db";
type DbRow = {
asset_id: string;
capture_ts_utc_override: string | null;
capture_offset_minutes_override: number | null;
created_at: string;
};
const createDbStub = (initial: DbRow) => {
let current = { ...initial };
const db = async <T>(
strings: TemplateStringsArray,
...values: unknown[]
): Promise<T> => {
const query = strings.join("");
if (query.includes("insert into asset_overrides")) {
const [assetId, captureTs, captureOffset, tsProvided, offsetProvided] =
values;
const hasFlags =
typeof tsProvided === "boolean" && typeof offsetProvided === "boolean";
const updateTs = hasFlags ? (tsProvided as boolean) : true;
const updateOffset = hasFlags ? (offsetProvided as boolean) : true;
if (updateTs) {
if (captureTs instanceof Date) {
current.capture_ts_utc_override = captureTs.toISOString();
} else if (captureTs === null) {
current.capture_ts_utc_override = null;
} else {
current.capture_ts_utc_override = String(captureTs ?? "");
}
}
if (updateOffset) {
current.capture_offset_minutes_override =
captureOffset as number | null;
}
return [
{
asset_id: String(assetId),
capture_ts_utc_override: current.capture_ts_utc_override,
capture_offset_minutes_override: current.capture_offset_minutes_override,
created_at: current.created_at,
},
] as T;
}
if (query.includes("insert into audit_log")) {
return [] as T;
}
if (query.includes("select capture_ts_utc") && query.includes("from assets")) {
return [{ capture_ts_utc: current.capture_ts_utc_override }] as T;
}
throw new Error(`Unexpected query: ${query}`);
};
return db as unknown as ReturnType<typeof getDb>;
};
test("asset overrides POST rejects when missing admin token", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const res = await handleSetCaptureOverride({
adminOk: false,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { captureTsUtcOverride: "2026-02-01T00:00:00.000Z" },
});
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("asset overrides POST rejects invalid body", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { captureTsUtcOverride: "not-a-date" },
});
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true);
});
test("asset overrides POST rejects unknown fields", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: {
captureTsUtcOverride: "2026-02-01T00:00:00.000Z",
extra: "nope",
},
});
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
});
test("asset overrides POST rejects string offset", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: {
captureOffsetMinutesOverride: "15",
},
});
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
});
test("asset overrides POST rejects empty body", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: {},
});
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
});
test("asset overrides POST preserves omitted fields", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const db = createDbStub({
asset_id: "00000000-0000-4000-8000-000000000000",
capture_ts_utc_override: "2026-01-01T00:00:00.000Z",
capture_offset_minutes_override: 90,
created_at: "2026-02-01T00:00:00.000Z",
});
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { captureTsUtcOverride: "2026-02-01T00:00:00.000Z" },
db,
});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
capture_ts_utc_override: "2026-02-01T00:00:00.000Z",
capture_offset_minutes_override: 90,
});
});
test("asset overrides POST allows explicit null clearing", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const db = createDbStub({
asset_id: "00000000-0000-4000-8000-000000000000",
capture_ts_utc_override: "2026-01-01T00:00:00.000Z",
capture_offset_minutes_override: 90,
created_at: "2026-02-01T00:00:00.000Z",
});
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { captureOffsetMinutesOverride: null },
db,
});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
capture_offset_minutes_override: null,
});
});
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { handleGetDupes } from "../../app/api/assets/[id]/dupes/handlers";
describe("handleGetDupes", () => {
test("returns empty list when hash is missing", async () => {
let call = 0;
const db = async () => {
call += 1;
return [] as unknown[];
};
const result = await handleGetDupes({
params: { id: "00000000-0000-0000-0000-000000000000" },
db,
});
expect(result.status).toBe(200);
expect(result.body).toEqual({ items: [] });
expect(call).toBe(1);
});
test("returns dupes excluding the asset id", async () => {
const calls: unknown[] = [];
const db = async () => {
calls.push(true);
if (calls.length === 1) {
return [{ bucket: "photos", sha256: "hash" }];
}
return [
{
id: "11111111-1111-1111-1111-111111111111",
media_type: "image",
status: "ready",
},
];
};
const result = await handleGetDupes({
params: { id: "00000000-0000-0000-0000-000000000000" },
db,
});
expect(result.status).toBe(200);
expect(result.body).toEqual({
items: [
{
id: "11111111-1111-1111-1111-111111111111",
media_type: "image",
status: "ready",
},
],
});
expect(calls.length).toBe(2);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { test, expect } from "bun:test";
test("shapeGeoRows returns id/lat/lon only", async () => {
const { shapeGeoRows } = await import("../../app/api/geo/shape");
const rows = [
{
id: "a",
gps_lat: 40.1,
gps_lon: -73.9,
capture_ts_utc: "2026-02-01T00:00:00.000Z",
media_type: "image",
},
];
expect(shapeGeoRows(rows)).toEqual([
{ id: "a", gps_lat: 40.1, gps_lon: -73.9 },
]);
});
+47
View File
@@ -0,0 +1,47 @@
import { test, expect } from "bun:test";
import { clusterMoments } from "../../app/lib/moments";
test("clusterMoments groups assets within 30 minutes", () => {
const clusters = clusterMoments([
{ id: "a", capture_ts_utc: "2026-02-01T10:00:00.000Z" },
{ id: "b", capture_ts_utc: "2026-02-01T10:20:00.000Z" },
{ id: "c", capture_ts_utc: "2026-02-01T10:49:00.000Z" },
]);
expect(clusters).toHaveLength(1);
expect(clusters[0]?.count).toBe(3);
});
test("clusterMoments splits after window gap", () => {
const clusters = clusterMoments([
{ id: "a", capture_ts_utc: "2026-02-01T10:00:00.000Z" },
{ id: "b", capture_ts_utc: "2026-02-01T11:05:00.000Z" },
]);
expect(clusters).toHaveLength(2);
expect(clusters[0]?.count).toBe(1);
expect(clusters[1]?.count).toBe(1);
});
test("clusterMoments sorts inputs per day", () => {
const clusters = clusterMoments([
{ id: "b", capture_ts_utc: "2026-02-01T10:20:00.000Z" },
{ id: "a", capture_ts_utc: "2026-02-01T10:00:00.000Z" },
{ id: "c", capture_ts_utc: "2026-02-01T10:40:00.000Z" },
]);
expect(clusters).toHaveLength(1);
expect(clusters[0]?.assets.map((a) => a.id)).toEqual(["a", "b", "c"]);
});
test("clusterMoments splits by day", () => {
const clusters = clusterMoments([
{ id: "a", capture_ts_utc: "2026-02-01T23:50:00.000Z" },
{ id: "b", capture_ts_utc: "2026-02-02T00:10:00.000Z" },
]);
expect(clusters).toHaveLength(2);
expect(clusters[0]?.day).toBe("2026-02-01");
expect(clusters[1]?.day).toBe("2026-02-02");
});
@@ -0,0 +1,18 @@
import { test, expect } from "bun:test";
import { pickVideoPlaybackVariant } from "../../app/lib/playback";
test("prefer mp4 derived over original", () => {
const picked = pickVideoPlaybackVariant({
originalMimeType: "video/x-matroska",
variants: [{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4" }],
});
expect(picked).toEqual({ kind: "video_mp4", size: 720 });
});
test("returns null when no mp4 variants", () => {
const picked = pickVideoPlaybackVariant({
originalMimeType: "video/x-matroska",
variants: [],
});
expect(picked).toBeNull();
});
+3
View File
@@ -0,0 +1,3 @@
import { expect, test } from "bun:test";
test("bun test runs", () => expect(1 + 1).toBe(2));
@@ -0,0 +1,83 @@
import { test, expect } from "bun:test";
function createMockDb(responses: Array<unknown>) {
const calls: Array<{ sql: string; values: unknown[] }> = [];
const db = async <T>(strings: TemplateStringsArray, ...values: unknown[]) => {
calls.push({ sql: strings.join(""), values });
const next = responses.shift();
return next as T;
};
return { db, calls };
}
test("tags POST rejects when missing admin token", async () => {
const { handleCreateTag } = await import("../../app/api/tags/handlers");
const res = await handleCreateTag({ adminOk: false, body: {} });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("tags GET rejects when missing admin token", async () => {
const { handleListTags } = await import("../../app/api/tags/handlers");
const res = await handleListTags({ adminOk: false });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("tags GET returns rows", async () => {
const { handleListTags } = await import("../../app/api/tags/handlers");
const { db } = createMockDb([
[
{
id: "00000000-0000-4000-8000-000000000001",
name: "Pets",
created_at: "2026-02-01T00:00:00.000Z",
},
],
]);
const res = await handleListTags({ adminOk: true, db: db as never });
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
id: "00000000-0000-4000-8000-000000000001",
name: "Pets",
created_at: "2026-02-01T00:00:00.000Z",
},
]);
});
test("tags POST inserts and writes audit log", async () => {
const { handleCreateTag } = await import("../../app/api/tags/handlers");
const { db, calls } = createMockDb([
[
{
id: "00000000-0000-4000-8000-000000000002",
name: "Trips",
created_at: "2026-02-01T00:00:00.000Z",
},
],
[],
]);
const res = await handleCreateTag({
adminOk: true,
body: { name: "Trips" },
db: db as never,
});
expect(res.status).toBe(200);
expect(res.body).toEqual({
id: "00000000-0000-4000-8000-000000000002",
name: "Trips",
created_at: "2026-02-01T00:00:00.000Z",
});
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
true,
);
});
test("tags POST rejects invalid body", async () => {
const { handleCreateTag } = await import("../../app/api/tags/handlers");
const res = await handleCreateTag({ adminOk: true, body: { name: "" } });
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true);
});
@@ -0,0 +1,33 @@
import { test, expect } from "bun:test";
test("variant lookup returns null when no matching variant", async () => {
const { pickVariantKey } = await import(
"../../app/api/assets/[id]/url/variant",
);
const key = pickVariantKey({ variants: [] }, { kind: "thumb", size: 256 });
expect(key).toBeNull();
});
test("legacy fallback maps kind+size to asset keys", async () => {
const { pickLegacyKeyForRequest } = await import(
"../../app/api/assets/[id]/url/variant",
);
const asset = {
thumb_small_key: "thumb-small",
thumb_med_key: "thumb-med",
poster_key: "poster",
};
expect(
pickLegacyKeyForRequest({ asset }, { kind: "thumb", size: 256 }),
).toBe("thumb-small");
expect(
pickLegacyKeyForRequest({ asset }, { kind: "thumb", size: 768 }),
).toBe("thumb-med");
expect(
pickLegacyKeyForRequest({ asset }, { kind: "poster", size: 256 }),
).toBe("poster");
expect(
pickLegacyKeyForRequest({ asset }, { kind: "thumb", size: 1024 }),
).toBeNull();
});
@@ -0,0 +1,13 @@
import { test, expect } from "bun:test";
test("variants route returns only kind/size/key fields", async () => {
const { shapeVariants } = await import(
"../../app/api/assets/[id]/variants/shape",
);
const rows = [
{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4", mime_type: "video/mp4" },
];
expect(shapeVariants(rows)).toEqual([
{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4" },
]);
});
@@ -0,0 +1,10 @@
import { test, expect } from "bun:test";
import { shouldTranscodeToMp4 } from "../transcode";
test("transcode runs for non-mp4 videos", () => {
expect(shouldTranscodeToMp4({ mimeType: "video/x-matroska" })).toBe(true);
});
test("transcode skips for mp4", () => {
expect(shouldTranscodeToMp4({ mimeType: "video/mp4" })).toBe(false);
});
@@ -0,0 +1,17 @@
import { test, expect } from "bun:test";
import { computeImageVariantPlan, pickSmallestVariantSize } from "../variants";
test("computeImageVariantPlan includes 256 and 768 thumbs", () => {
expect(computeImageVariantPlan()).toEqual([
{ kind: "thumb", size: 256 },
{ kind: "thumb", size: 768 },
]);
});
test("pickSmallestVariantSize returns smallest poster size", () => {
const size = pickSmallestVariantSize([
{ kind: "poster", size: 768 },
{ kind: "poster", size: 256 },
]);
expect(size).toBe(256);
});
+12
View File
@@ -0,0 +1,12 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
export async function computeFileSha256(filePath: string): Promise<string> {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
return await new Promise((resolve, reject) => {
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
+3 -1
View File
@@ -7,7 +7,8 @@ import { closeDb } from "@tline/db";
import {
handleCopyToCanonical,
handleProcessAsset,
handleScanMinioPrefix
handleScanMinioPrefix,
handleTranscodeVideoMp4
} from "./jobs";
console.log(`[${getAppName()}] worker boot`);
@@ -30,6 +31,7 @@ const worker = new Worker(
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);
if (job.name === "transcode_video_mp4") return handleTranscodeVideoMp4(job.data);
throw new Error(`Unknown job: ${job.name}`);
},
+330 -54
View File
@@ -7,6 +7,12 @@ import { Readable } from "stream";
import sharp from "sharp";
import {
computeImageVariantPlan,
computeVideoPosterPlan,
pickSmallestVariantSize,
} from "./variants";
import {
CopyObjectCommand,
GetObjectCommand,
@@ -21,10 +27,15 @@ import {
copyToCanonicalPayloadSchema,
enqueueCopyToCanonical,
enqueueProcessAsset,
enqueueTranscodeVideoMp4,
processAssetPayloadSchema,
scanMinioPrefixPayloadSchema,
transcodeVideoMp4PayloadSchema,
} from "@tline/queue";
import { shouldTranscodeToMp4 } from "./transcode";
import { computeFileSha256 } from "./hash-utils";
const allowedScanPrefixes = ["originals/"] as const;
function assertAllowedScanPrefix(prefix: string) {
@@ -205,6 +216,45 @@ async function uploadObject(input: {
);
}
async function upsertVariant(input: {
assetId: string;
kind: "thumb" | "poster" | "video_mp4";
size: number;
key: string;
mimeType: string;
width?: number | null;
height?: number | null;
}) {
const db = getDb();
await db`
insert into asset_variants (asset_id, kind, size, key, mime_type, width, height)
values (
${input.assetId},
${input.kind},
${input.size},
${input.key},
${input.mimeType},
${input.width ?? null},
${input.height ?? null}
)
on conflict (asset_id, kind, size)
do update set key = excluded.key,
mime_type = excluded.mime_type,
width = excluded.width,
height = excluded.height
`;
}
async function upsertAssetHash(input: { assetId: string; bucket: string; sha256: string }) {
const db = getDb();
await db`
insert into asset_hashes (asset_id, bucket, sha256)
values (${input.assetId}, ${input.bucket}, ${input.sha256})
on conflict (asset_id)
do update set sha256 = excluded.sha256, bucket = excluded.bucket
`;
}
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 }));
@@ -232,6 +282,105 @@ function parseExifDate(dateStr: string | undefined): Date | null {
return isNaN(date.getTime()) ? null : date;
}
function parseGpsParts(parts: number[]): number | null {
if (parts.length === 0 || !Number.isFinite(parts[0])) return null;
const [deg, min, sec] = parts;
const sign = deg < 0 ? -1 : 1;
let value = Math.abs(deg);
if (Number.isFinite(min)) value += Math.abs(min) / 60;
if (Number.isFinite(sec)) value += Math.abs(sec) / 3600;
return sign * value;
}
function parseGpsFraction(input: string): number | null {
const trimmed = input.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/);
if (!match) return null;
const numerator = Number(match[1]);
const denominator = Number(match[2]);
if (!Number.isFinite(numerator) || !Number.isFinite(denominator)) return null;
if (denominator === 0) return null;
return numerator / denominator;
}
function parseGpsValue(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return null;
const direct = Number(trimmed);
if (!Number.isNaN(direct)) return direct;
const fraction = parseGpsFraction(trimmed);
if (fraction !== null) return fraction;
const parts = trimmed.match(/-?\d+(?:\.\d+)?/g);
if (!parts) return null;
return parseGpsParts(parts.map((part) => Number(part)).filter(Number.isFinite));
}
if (Array.isArray(value)) {
const parts = value
.map((part) => {
if (typeof part === "number") return part;
if (typeof part === "string") {
const fraction = parseGpsFraction(part);
if (fraction !== null) return fraction;
return Number(part);
}
if (typeof part === "object" && part !== null) {
const candidate = part as Record<string, unknown>;
const numerator = Number(candidate.numerator);
const denominator = Number(candidate.denominator);
if (Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0) {
return numerator / denominator;
}
}
return NaN;
})
.filter(Number.isFinite);
return parseGpsParts(parts);
}
return null;
}
function applyRefSign(value: number, ref: unknown, valueRaw: unknown): number {
const refChar = typeof ref === "string" ? ref.trim().toUpperCase() : "";
const rawChar =
typeof valueRaw === "string"
? (valueRaw.trim().match(/[NSEW]/i)?.[0]?.toUpperCase() ?? "")
: "";
const normalized = refChar || rawChar;
if (normalized === "S" || normalized === "W") return -Math.abs(value);
if (normalized === "N" || normalized === "E") return Math.abs(value);
return value;
}
function parseGpsCoord(
value: unknown,
ref: unknown,
kind: "lat" | "lon",
): number | null {
const parsed = parseGpsValue(value);
if (parsed === null) return null;
const signed = applyRefSign(parsed, ref, value);
if (!Number.isFinite(signed)) return null;
if (kind === "lat") {
return signed >= -90 && signed <= 90 ? signed : null;
}
return signed >= -180 && signed <= 180 ? signed : null;
}
function extractGps(tags: Record<string, unknown>) {
const lat = parseGpsCoord(tags.GPSLatitude, tags.GPSLatitudeRef, "lat");
const lon = parseGpsCoord(tags.GPSLongitude, tags.GPSLongitudeRef, "lon");
if (lat === null || lon === null) return null;
return { lat, lon };
}
function isPlausibleCaptureTs(date: Date) {
const ts = date.getTime();
if (!Number.isFinite(ts)) return false;
@@ -299,10 +448,13 @@ export async function handleProcessAsset(raw: unknown) {
Key: asset.active_key,
}),
);
if (!getRes.Body) throw new Error("Empty response body from S3");
await streamToFile(getRes.Body as Readable, inputPath);
if (!getRes.Body) throw new Error("Empty response body from S3");
await streamToFile(getRes.Body as Readable, inputPath);
const updates: Record<string, unknown> = {
const sha256 = await computeFileSha256(inputPath);
await upsertAssetHash({ assetId: asset.id, bucket: asset.bucket, sha256 });
const updates: Record<string, unknown> = {
capture_ts_utc: null,
date_confidence: null,
width: null,
@@ -312,7 +464,9 @@ export async function handleProcessAsset(raw: unknown) {
thumb_small_key: null,
thumb_med_key: null,
poster_key: null,
raw_tags_json: null
raw_tags_json: null,
gps_lat: null,
gps_lon: null
};
let rawTags: Record<string, unknown> = {};
let captureTs: Date | null = null;
@@ -386,6 +540,11 @@ export async function handleProcessAsset(raw: unknown) {
if (asset.media_type === "image") {
rawTags = await tryReadExifTags();
maybeSetCaptureDateFromTags(rawTags);
const gps = extractGps(rawTags);
if (gps) {
updates.gps_lat = gps.lat;
updates.gps_lon = gps.lon;
}
await applyObjectMtimeFallback();
@@ -397,38 +556,45 @@ export async function handleProcessAsset(raw: unknown) {
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 imagePlan = computeImageVariantPlan();
const thumbKeys: Record<number, string> = {};
for (const item of imagePlan) {
const size = item.size;
const thumbPath = join(tempDir, `thumb_${size}.jpg`);
await sharp(inputPath)
.rotate()
.resize(size, size, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(thumbPath);
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;
const thumbKey = `thumbs/${asset.id}/image_${size}.jpg`;
await uploadObject({
bucket: asset.bucket,
key: thumbKey,
filePath: thumbPath,
contentType: "image/jpeg",
});
await upsertVariant({
assetId: asset.id,
kind: "thumb",
size,
key: thumbKey,
mimeType: "image/jpeg",
width: typeof updates.width === "number" ? updates.width : null,
height: typeof updates.height === "number" ? updates.height : null,
});
thumbKeys[size] = thumbKey;
}
updates.thumb_small_key = thumbKeys[256] ?? null;
updates.thumb_med_key = thumbKeys[768] ?? null;
} else if (asset.media_type === "video") {
rawTags = await tryReadExifTags();
maybeSetCaptureDateFromTags(rawTags);
const gps = extractGps(rawTags);
if (gps) {
updates.gps_lat = gps.lat;
updates.gps_lon = gps.lon;
}
const ffprobeOutput = await runCommand("ffprobe", [
"-v",
@@ -465,27 +631,43 @@ export async function handleProcessAsset(raw: unknown) {
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;
const posterPlan = computeVideoPosterPlan();
const posterSmallest = pickSmallestVariantSize(posterPlan);
const posterKeys: Record<number, string> = {};
for (const item of posterPlan) {
const size = item.size;
const posterPath = join(tempDir, `poster_${size}.jpg`);
await runCommand("ffmpeg", [
"-i",
inputPath,
"-vf",
`scale=${size}:${size}:force_original_aspect_ratio=decrease`,
"-vframes",
"1",
"-q:v",
"2",
"-y",
posterPath
]);
const posterKey = `thumbs/${asset.id}/poster_${size}.jpg`;
await uploadObject({
bucket: asset.bucket,
key: posterKey,
filePath: posterPath,
contentType: "image/jpeg",
});
await upsertVariant({
assetId: asset.id,
kind: "poster",
size,
key: posterKey,
mimeType: "image/jpeg",
width: typeof updates.width === "number" ? updates.width : null,
height: typeof updates.height === "number" ? updates.height : null,
});
posterKeys[size] = posterKey;
}
updates.poster_key = posterSmallest ? posterKeys[posterSmallest] ?? null : null;
}
if (asset.media_type === "video" && typeof updates.poster_key !== "string") {
@@ -526,11 +708,17 @@ export async function handleProcessAsset(raw: unknown) {
"thumb_small_key",
"thumb_med_key",
"poster_key",
"raw_tags_json"
"raw_tags_json",
"gps_lat",
"gps_lon"
)}, status = 'ready', error_message = null
where id = ${asset.id}
`;
if (asset.media_type === "video" && shouldTranscodeToMp4({ mimeType: asset.mime_type })) {
await enqueueTranscodeVideoMp4({ assetId: asset.id });
}
// Only uploads (staging/*) are copied into canonical by default.
if (asset.active_key.startsWith("staging/")) {
await enqueueCopyToCanonical({ assetId: asset.id });
@@ -553,6 +741,94 @@ export async function handleProcessAsset(raw: unknown) {
}
}
export async function handleTranscodeVideoMp4(raw: unknown) {
const payload = transcodeVideoMp4PayloadSchema.parse(raw);
const db = getDb();
const s3 = getMinioInternalClient();
const [asset] = await db<
{
id: string;
bucket: string;
active_key: string;
mime_type: string;
}[]
>`
select id, bucket, active_key, mime_type
from assets
where id = ${payload.assetId}
limit 1
`;
if (!asset) throw new Error(`Asset not found: ${payload.assetId}`);
if (!shouldTranscodeToMp4({ mimeType: asset.mime_type })) {
return { ok: true, assetId: asset.id, skipped: "already_mp4" };
}
const tempDir = await mkdtemp(join(tmpdir(), "tline-transcode-"));
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 sha256 = await computeFileSha256(inputPath);
await upsertAssetHash({ assetId: asset.id, bucket: asset.bucket, sha256 });
const outputPath = join(tempDir, "mp4_720p.mp4");
await runCommand("ffmpeg", [
"-i",
inputPath,
"-vf",
"scale=-2:720",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
"-y",
outputPath,
]);
const derivedKey = `derived/video/${asset.id}/mp4_720p.mp4`;
await uploadObject({
bucket: asset.bucket,
key: derivedKey,
filePath: outputPath,
contentType: "video/mp4",
});
await upsertVariant({
assetId: asset.id,
kind: "video_mp4",
size: 720,
key: derivedKey,
mimeType: "video/mp4",
width: null,
height: 720,
});
return { ok: true, assetId: asset.id, key: derivedKey };
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
export async function handleCopyToCanonical(raw: unknown) {
const payload = copyToCanonicalPayloadSchema.parse(raw);
+3
View File
@@ -0,0 +1,3 @@
export function shouldTranscodeToMp4(input: { mimeType: string }) {
return input.mimeType !== "video/mp4";
}
+23
View File
@@ -0,0 +1,23 @@
export type VariantPlanItem = {
kind: "thumb" | "poster";
size: number;
};
export function pickSmallestVariantSize(plan: VariantPlanItem[]): number | null {
if (plan.length === 0) return null;
return plan.reduce((min, item) => (item.size < min ? item.size : min), plan[0].size);
}
export function computeImageVariantPlan(): VariantPlanItem[] {
return [
{ kind: "thumb", size: 256 },
{ kind: "thumb", size: 768 },
];
}
export function computeVideoPosterPlan(): VariantPlanItem[] {
return [
{ kind: "poster", size: 256 },
{ kind: "poster", size: 768 },
];
}
+13
View File
@@ -5,9 +5,12 @@
"": {
"name": "tline",
"dependencies": {
"leaflet": "^1.9.4",
"react-leaflet": "^5.0.0",
"zod": "^4.2.1",
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/node": "^20.19.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -272,6 +275,8 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw=="],
"@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="],
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw=="],
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="],
@@ -390,8 +395,12 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="],
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
@@ -518,6 +527,8 @@
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -576,6 +587,8 @@
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
"react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
@@ -0,0 +1,109 @@
# Use Playback Selector Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add an asset variants endpoint and wire MediaPanel to use `pickVideoPlaybackVariant` for derived MP4 selection with a safe fallback.
**Architecture:** Introduce a minimal `/api/assets/:id/variants` route that returns `{ kind, size, key }` from `asset_variants`. MediaPanel fetches variants on-demand for videos, uses `pickVideoPlaybackVariant` to decide whether to request `video_mp4` (size 720), and falls back to original if the derived URL fails.
**Tech Stack:** Next.js App Router API routes, Postgres via `@tline/db`, Bun test runner.
### Task 1: Add variants API route
**Files:**
- Create: `apps/web/app/api/assets/[id]/variants/route.ts`
- Test: `apps/web/src/__tests__/variants-route.test.ts`
**Step 1: Write the failing test**
Create `apps/web/src/__tests__/variants-route.test.ts`:
```ts
import { test, expect } from "bun:test";
test("variants route returns only kind/size/key fields", async () => {
const { shapeVariants } = await import("../../app/api/assets/[id]/variants/shape");
const rows = [
{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4", mime_type: "video/mp4" },
];
expect(shapeVariants(rows)).toEqual([{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4" }]);
});
```
**Step 2: Run test to verify it fails**
Run: `bun test apps/web/src/__tests__/variants-route.test.ts`
Expected: FAIL (missing module or function)
**Step 3: Write minimal implementation**
- Create `apps/web/app/api/assets/[id]/variants/shape.ts` with `shapeVariants(rows)` that returns `{ kind, size, key }` only.
- Create `apps/web/app/api/assets/[id]/variants/route.ts`:
- Validate `id` with `z.string().uuid()`
- Query `asset_variants` by `asset_id`
- Return JSON array of `shapeVariants(rows)`
**Step 4: Run test to verify it passes**
Run: `bun test apps/web/src/__tests__/variants-route.test.ts`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/web/app/api/assets/[id]/variants/route.ts apps/web/app/api/assets/[id]/variants/shape.ts \
apps/web/src/__tests__/variants-route.test.ts
git commit -m "feat: add asset variants endpoint"
```
### Task 2: Use playback selector in MediaPanel
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
- Modify: `apps/web/app/lib/playback.ts`
- Test: `apps/web/src/__tests__/prefer-derived.test.ts`
**Step 1: Write the failing test**
Add to `apps/web/src/__tests__/prefer-derived.test.ts`:
```ts
import { test, expect } from "bun:test";
import { pickVideoPlaybackVariant } from "../../app/lib/playback";
test("pickVideoPlaybackVariant returns null when no variants", () => {
expect(
pickVideoPlaybackVariant({
originalMimeType: "video/x-matroska",
variants: [],
}),
).toBeNull();
});
```
**Step 2: Run test to verify it fails**
Run: `bun test apps/web/src/__tests__/prefer-derived.test.ts`
Expected: FAIL (function does not handle empty variants)
**Step 3: Write minimal implementation**
- Update `pickVideoPlaybackVariant` to return `null` when no `video_mp4` variants exist.
- Update `MediaPanel` video URL loader to:
1) Fetch `/api/assets/:id/variants`
2) Call `pickVideoPlaybackVariant`
3) If variant found → request `kind=video_mp4&size=720`
4) If not found or fetch fails → request `variant=original`
**Step 4: Run test to verify it passes**
Run: `bun test apps/web/src/__tests__/prefer-derived.test.ts`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/web/app/components/MediaPanel.tsx apps/web/app/lib/playback.ts \
apps/web/src/__tests__/prefer-derived.test.ts
git commit -m "fix: use playback selector in MediaPanel"
```
+89
View File
@@ -0,0 +1,89 @@
# Tags/Albums UI Wiring Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add minimal admin UI to manage tags/albums and wire asset detail UI to assign tags and add assets to albums.
**Architecture:** Keep UI changes local to existing Next.js components. Use lightweight fetch calls to existing `/api/tags` and `/api/albums` endpoints with admin header set from sessionStorage, plus inline error handling. Avoid new state management or styling systems.
**Tech Stack:** Next.js app router, React, TypeScript, fetch API, inline styles/Tailwind classes.
### Task 1: Establish admin token input + list/create tags/albums UI
**Files:**
- Modify: `apps/web/app/admin/page.tsx`
**Step 1: Write the failing test**
No tests for UI wiring are added per user-approved TDD exception. Record rationale in implementation notes.
**Step 2: Run test to verify it fails**
Skipped.
**Step 3: Write minimal implementation**
- Convert page to client component.
- Add admin token form that reads/writes `sessionStorage`.
- Add list + create for tags and albums using `fetch` with `X-Porthole-Admin-Token` header.
- Inline errors per section.
**Step 4: Run test to verify it passes**
Skipped.
**Step 5: Commit**
Include with other tasks once all UI wiring is complete.
### Task 2: Asset detail UI for tag assignment and album add
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Step 1: Write the failing test**
No tests for UI wiring are added per user-approved TDD exception. Record rationale in implementation notes.
**Step 2: Run test to verify it fails**
Skipped.
**Step 3: Write minimal implementation**
- Add UI section in viewer panel to assign tag(s) to the current asset and add asset to album.
- Fetch tags/albums lists using admin token from `sessionStorage`.
- Use inline error handling and disable actions when missing token/asset.
**Step 4: Run test to verify it passes**
Skipped.
**Step 5: Commit**
Include with other tasks once all UI wiring is complete.
### Task 3: Validate behavior manually
**Files:**
- None
**Step 1: Write the failing test**
No tests for UI wiring are added per user-approved TDD exception. Record rationale in implementation notes.
**Step 2: Run test to verify it fails**
Skipped.
**Step 3: Manual smoke**
- Load `/admin` page, set token, create tag/album, verify list refresh.
- Open asset viewer in media panel, assign tag/add to album, confirm inline success/error states.
**Step 4: Commit**
```bash
git add apps/web/app/admin/page.tsx apps/web/app/components/MediaPanel.tsx docs/plans/2026-02-02-tags-albums-ui.md
git commit -m "feat: add tags/albums UI"
```
@@ -0,0 +1,138 @@
# Capture Time Override UI Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a capture-time override form in the MediaPanel viewer to POST override timestamps and display current effective/base timestamps.
**Architecture:** Extend the existing MediaPanel viewer admin controls with a small form that reads/writes to `/api/assets/:id/override-capture-ts` using the existing admin token from sessionStorage. Keep UI state local to MediaPanel and refresh the viewer asset timestamps after submit.
**Tech Stack:** React (Next.js app router), TypeScript, fetch API
### Task 1: Add override state and helpers
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Step 1: Write the failing test**
No tests (user-approved).
**Step 2: Add state for override input, status, and effective/base timestamps**
```ts
const [captureOverrideInput, setCaptureOverrideInput] = useState("");
const [captureOverrideError, setCaptureOverrideError] = useState<string | null>(null);
const [captureOverrideBusy, setCaptureOverrideBusy] = useState(false);
```
**Step 3: Add helper to derive effective/base timestamp**
```ts
const effectiveTs = viewer?.asset.capture_ts_utc ?? null;
const baseTs = viewer?.asset.capture_ts_utc ?? null; // updated when override applied
```
**Step 4: Commit**
No commit yet; continue tasks.
### Task 2: Add override POST handler
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Step 1: Write the failing test**
No tests (user-approved).
**Step 2: Implement submit handler**
```ts
async function handleOverrideCaptureTs() {
if (!viewer) return;
setCaptureOverrideError(null);
setCaptureOverrideBusy(true);
try {
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
if (!token) throw new Error("missing_admin_token");
const res = await fetch(`/api/assets/${viewer.asset.id}/override-capture-ts`, {
method: "POST",
headers: {
"X-Porthole-Admin-Token": token,
"Content-Type": "application/json",
},
body: JSON.stringify({ capture_ts_utc: captureOverrideInput || null }),
});
if (!res.ok) throw new Error(`override_failed:${res.status}`);
// refresh viewer asset timestamps (re-fetch list or update local)
} catch (err) {
setCaptureOverrideError(err instanceof Error ? err.message : String(err));
} finally {
setCaptureOverrideBusy(false);
}
}
```
**Step 3: Commit**
No commit yet; continue tasks.
### Task 3: Add UI above Tags & Albums
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Step 1: Write the failing test**
No tests (user-approved).
**Step 2: Add form UI**
```tsx
<div style={{ display: "grid", gap: 6 }}>
<strong style={{ fontSize: 13 }}>Capture time override</strong>
<div style={{ color: "#666", fontSize: 12 }}>
Effective: {effectiveTs ?? "(none)"}
</div>
<div style={{ color: "#999", fontSize: 12 }}>
Base: {baseTs ?? "(unknown)"}
</div>
<div style={{ display: "flex", gap: 8 }}>
<input
type="text"
placeholder="2026-01-01T00:00:00.000Z"
value={captureOverrideInput}
onChange={(e) => setCaptureOverrideInput(e.target.value)}
style={{ flex: 1, padding: 6 }}
disabled={captureOverrideBusy}
/>
<button type="button" onClick={handleOverrideCaptureTs}>
Save
</button>
</div>
{captureOverrideError ? (
<div style={{ color: "#b00", fontSize: 12 }}>{captureOverrideError}</div>
) : null}
</div>
```
**Step 3: Commit**
No commit yet; continue tasks.
### Task 4: Finalize, verify, and commit
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Step 1: Quick manual check**
Run: `npm test` (skip)
Expected: (skipped per user)
**Step 2: Commit**
```bash
git add apps/web/app/components/MediaPanel.tsx
git commit -m "feat: add UI for capture time override"
```
@@ -0,0 +1,73 @@
{{- if and .Values.jobs.applyLifecycle.enabled .Values.minio.enabled -}}
{{- $thumbsPrefix := required "applyLifecycle.prefixes.thumbs is required" .Values.jobs.applyLifecycle.prefixes.thumbs -}}
{{- $derivedPrefix := required "applyLifecycle.prefixes.derived is required" .Values.jobs.applyLifecycle.prefixes.derived -}}
{{- if or (eq $thumbsPrefix "") (eq $derivedPrefix "") -}}
{{- fail "applyLifecycle prefixes must be non-empty" -}}
{{- end -}}
{{- if or (eq $thumbsPrefix "originals/") (eq $derivedPrefix "originals/") (eq $thumbsPrefix "originals") (eq $derivedPrefix "originals") -}}
{{- fail "applyLifecycle prefixes must not target originals" -}}
{{- end -}}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "apply-lifecycle") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: apply-lifecycle
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-15"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 2
template:
metadata:
labels:
{{ include "tline.selectorLabels" . | indent 8 }}
app.kubernetes.io/component: apply-lifecycle
spec:
restartPolicy: Never
{{ include "tline.imagePullSecrets" . | indent 6 }}
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
{{- if $aff }}
affinity:
{{ $aff | indent 8 }}
{{- end }}
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
{{- if $tols }}
tolerations:
{{ $tols | indent 8 }}
{{- end }}
containers:
- name: apply-lifecycle
image: {{ printf "%s:%s" .Values.jobs.applyLifecycle.image.repository .Values.jobs.applyLifecycle.image.tag | quote }}
imagePullPolicy: {{ .Values.jobs.applyLifecycle.image.pullPolicy }}
command:
- sh
- -c
- |
set -eu
echo "Configuring mc alias..."
{{- $minioSvc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") -}}
{{- $minioEndpoint := printf "http://%s:%d" $minioSvc (.Values.minio.service.s3Port | int) -}}
mc alias set local {{ $minioEndpoint | quote }} "$MINIO_ACCESS_KEY_ID" "$MINIO_SECRET_ACCESS_KEY"
echo "Applying lifecycle policy ({{ .Values.jobs.applyLifecycle.expire_days }}d) for derived objects..."
mc ilm add --expire-days {{ .Values.jobs.applyLifecycle.expire_days | int }} --prefix {{ .Values.jobs.applyLifecycle.prefixes.thumbs | quote }} "local/{{ .Values.app.minio.bucket }}"
mc ilm add --expire-days {{ .Values.jobs.applyLifecycle.expire_days | int }} --prefix {{ .Values.jobs.applyLifecycle.prefixes.derived | quote }} "local/{{ .Values.app.minio.bucket }}"
# Never mutate or delete originals/**. This job applies lifecycle rules only.
env:
- name: MINIO_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_ACCESS_KEY_ID
- name: MINIO_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_SECRET_ACCESS_KEY
resources:
{{ toYaml .Values.jobs.applyLifecycle.resources | indent 12 }}
{{- end }}
+18
View File
@@ -231,6 +231,24 @@ jobs:
cpu: 300m
memory: 256Mi
applyLifecycle:
enabled: false
expire_days: 30
prefixes:
thumbs: thumbs/
derived: derived/
image:
repository: minio/mc
tag: RELEASE.2024-01-16T16-07-38Z
pullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 300m
memory: 256Mi
migrate:
enabled: true
image:
+4
View File
@@ -9,11 +9,13 @@
"packages/*"
],
"scripts": {
"test": "bun test",
"typecheck": "bunx tsc -p packages/config/tsconfig.json --noEmit && bunx tsc -p packages/db/tsconfig.json --noEmit && bunx tsc -p packages/minio/tsconfig.json --noEmit && bunx tsc -p packages/queue/tsconfig.json --noEmit && bunx tsc -p apps/worker/tsconfig.json --noEmit && bunx tsc -p apps/web/tsconfig.json --noEmit",
"lint": "bunx eslint .",
"format": "bunx prettier . --check"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/node": "^20.19.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -23,6 +25,8 @@
"typescript": "^5.9.3"
},
"dependencies": {
"leaflet": "^1.9.4",
"react-leaflet": "^5.0.0",
"zod": "^4.2.1"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { test, expect } from "bun:test";
import { isAdminRequest } from "./adminAuth";
test("isAdminRequest returns false when ADMIN_TOKEN unset", () => {
expect(isAdminRequest({ adminToken: undefined }, { headerToken: "x" })).toBe(
false,
);
});
test("isAdminRequest returns true when header token matches", () => {
expect(
isAdminRequest({ adminToken: "secret" }, { headerToken: "secret" }),
).toBe(true);
});
+7
View File
@@ -0,0 +1,7 @@
export function isAdminRequest(
env: { adminToken: string | undefined },
input: { headerToken: string | null | undefined },
) {
if (!env.adminToken) return false;
return input.headerToken === env.adminToken;
}
+21 -1
View File
@@ -1,8 +1,13 @@
import { z } from "zod";
export { isAdminRequest } from "./adminAuth";
const envSchema = z.object({
APP_NAME: z.string().min(1).default("porthole"),
NEXT_PUBLIC_APP_NAME: z.string().min(1).optional()
NEXT_PUBLIC_APP_NAME: z.string().min(1).optional(),
ADMIN_TOKEN: z.string().min(1).optional(),
MINIO_PUBLIC_ENDPOINT_LAN: z.string().url().optional(),
MINIO_ENDPOINT_MODE: z.enum(["tailnet", "lan", "auto"]).default("auto"),
});
let cachedEnv: z.infer<typeof envSchema> | undefined;
@@ -23,3 +28,18 @@ export function getAppName() {
const env = getEnv();
return env.NEXT_PUBLIC_APP_NAME ?? env.APP_NAME;
}
export function getAdminToken() {
const env = getEnv();
return env.ADMIN_TOKEN;
}
export function getMinioEndpointMode() {
const env = getEnv();
return env.MINIO_ENDPOINT_MODE;
}
export function getMinioPublicEndpointLan() {
const env = getEnv();
return env.MINIO_PUBLIC_ENDPOINT_LAN;
}
@@ -0,0 +1,20 @@
CREATE TYPE IF NOT EXISTS asset_variant_kind AS ENUM (
'thumb',
'poster',
'video_mp4'
);
CREATE TABLE IF NOT EXISTS asset_variants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
kind asset_variant_kind NOT NULL,
size int NOT NULL,
key text NOT NULL,
mime_type text NOT NULL,
width int,
height int,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(asset_id, kind, size)
);
CREATE INDEX IF NOT EXISTS asset_variants_asset_id_idx ON asset_variants(asset_id);
@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS tags (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS asset_tags (
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY(asset_id, tag_id)
);
CREATE TABLE IF NOT EXISTS albums (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS album_assets (
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
ord int,
PRIMARY KEY(album_id, asset_id)
);
CREATE TABLE IF NOT EXISTS audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
actor text NOT NULL,
action text NOT NULL,
entity_type text NOT NULL,
entity_id uuid,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS asset_overrides (
asset_id uuid PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,
capture_ts_utc_override timestamptz,
capture_offset_minutes_override int,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS asset_overrides_capture_ts_idx
ON asset_overrides(capture_ts_utc_override);
@@ -0,0 +1,3 @@
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS gps_lat double precision,
ADD COLUMN IF NOT EXISTS gps_lon double precision;
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS asset_hashes (
asset_id uuid PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,
bucket text NOT NULL,
sha256 text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS asset_hashes_bucket_sha256_idx
ON asset_hashes(bucket, sha256) WHERE sha256 IS NOT NULL;
@@ -0,0 +1,52 @@
import { expect, test } from "bun:test";
import { resolvePresignEndpoint } from "./endpointSelector";
import type { MinioEnv } from "./env";
const baseEnv: MinioEnv = {
MINIO_INTERNAL_ENDPOINT: "http://minio:9000",
MINIO_PUBLIC_ENDPOINT_TS: "https://ts.example.com",
MINIO_PUBLIC_ENDPOINT_LAN: "https://lan.example.com",
MINIO_ACCESS_KEY_ID: "key",
MINIO_SECRET_ACCESS_KEY: "secret",
MINIO_REGION: "us-east-1",
MINIO_BUCKET: "media",
MINIO_PRESIGN_EXPIRES_SECONDS: 900,
MINIO_ENDPOINT_MODE: "auto",
};
test("auto endpoint mode defaults to tailnet", () => {
expect(resolvePresignEndpoint(baseEnv, undefined)).toBe(
"https://ts.example.com",
);
});
test("endpoint=lan forces LAN endpoint", () => {
expect(resolvePresignEndpoint(baseEnv, "lan")).toBe(
"https://lan.example.com",
);
});
test("endpoint=tailnet forces tailnet endpoint", () => {
expect(resolvePresignEndpoint(baseEnv, "tailnet")).toBe(
"https://ts.example.com",
);
});
test("lan mode selects LAN endpoint", () => {
const env = { ...baseEnv, MINIO_ENDPOINT_MODE: "lan" as const };
expect(resolvePresignEndpoint(env, undefined)).toBe(
"https://lan.example.com",
);
});
test("lan mode without LAN endpoint throws", () => {
const env = {
...baseEnv,
MINIO_ENDPOINT_MODE: "lan" as const,
MINIO_PUBLIC_ENDPOINT_LAN: undefined,
};
expect(() => resolvePresignEndpoint(env, undefined)).toThrow(
"MINIO_PUBLIC_ENDPOINT_LAN is required",
);
});
+22
View File
@@ -0,0 +1,22 @@
import type { MinioEnv } from "./env";
export type PresignEndpointOverride = "lan" | "tailnet";
export function resolvePresignEndpoint(
env: MinioEnv,
override?: PresignEndpointOverride,
) {
const mode = override ?? env.MINIO_ENDPOINT_MODE;
if (mode === "lan") {
if (!env.MINIO_PUBLIC_ENDPOINT_LAN) {
throw new Error("MINIO_PUBLIC_ENDPOINT_LAN is required for lan endpoint mode");
}
return env.MINIO_PUBLIC_ENDPOINT_LAN;
}
if (!env.MINIO_PUBLIC_ENDPOINT_TS) {
throw new Error(
"MINIO_PUBLIC_ENDPOINT_TS is required for presigned URL generation",
);
}
return env.MINIO_PUBLIC_ENDPOINT_TS;
}
+31
View File
@@ -0,0 +1,31 @@
import { z } from "zod";
export const envSchema = z.object({
MINIO_INTERNAL_ENDPOINT: z.string().url().optional(),
MINIO_PUBLIC_ENDPOINT_TS: z.string().url().optional(),
MINIO_PUBLIC_ENDPOINT_LAN: z.string().url().optional(),
MINIO_ACCESS_KEY_ID: z.string().min(1),
MINIO_SECRET_ACCESS_KEY: z.string().min(1),
MINIO_REGION: z.string().min(1).default("us-east-1"),
MINIO_BUCKET: z.string().min(1).default("media"),
MINIO_PRESIGN_EXPIRES_SECONDS: z.coerce
.number()
.int()
.positive()
.default(900),
MINIO_ENDPOINT_MODE: z.enum(["tailnet", "lan", "auto"]).default("auto"),
});
export type MinioEnv = z.infer<typeof envSchema>;
let cachedEnv: MinioEnv | undefined;
export function getMinioEnv(): MinioEnv {
if (cachedEnv) return cachedEnv;
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(`Invalid MinIO env: ${parsed.error.message}`);
}
cachedEnv = parsed.data;
return cachedEnv;
}
+22 -35
View File
@@ -2,33 +2,16 @@ import "server-only";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { z } from "zod";
import { getMinioEnv, type MinioEnv } from "./env";
import {
resolvePresignEndpoint,
type PresignEndpointOverride,
} from "./endpointSelector";
const envSchema = z.object({
MINIO_INTERNAL_ENDPOINT: z.string().url().optional(),
MINIO_PUBLIC_ENDPOINT_TS: z.string().url().optional(),
MINIO_ACCESS_KEY_ID: z.string().min(1),
MINIO_SECRET_ACCESS_KEY: z.string().min(1),
MINIO_REGION: z.string().min(1).default("us-east-1"),
MINIO_BUCKET: z.string().min(1).default("media"),
MINIO_PRESIGN_EXPIRES_SECONDS: z.coerce.number().int().positive().default(900)
});
type MinioEnv = z.infer<typeof envSchema>;
let cachedEnv: MinioEnv | undefined;
let cachedInternal: S3Client | undefined;
let cachedPublic: S3Client | undefined;
export function getMinioEnv(): MinioEnv {
if (cachedEnv) return cachedEnv;
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(`Invalid MinIO env: ${parsed.error.message}`);
}
cachedEnv = parsed.data;
return cachedEnv;
}
export type { MinioEnv, PresignEndpointOverride };
export function getMinioBucket() {
return getMinioEnv().MINIO_BUCKET;
@@ -54,24 +37,27 @@ export function getMinioInternalClient(): S3Client {
return cachedInternal;
}
export function getMinioPublicSigningClient(): S3Client {
if (cachedPublic) return cachedPublic;
export function getMinioPublicSigningClient(
override?: PresignEndpointOverride,
): S3Client {
if (!override && cachedPublic) return cachedPublic;
const env = getMinioEnv();
if (!env.MINIO_PUBLIC_ENDPOINT_TS) {
throw new Error("MINIO_PUBLIC_ENDPOINT_TS is required for presigned URL generation");
}
cachedPublic = new S3Client({
const endpoint = resolvePresignEndpoint(env, override);
const client = new S3Client({
region: env.MINIO_REGION,
endpoint: env.MINIO_PUBLIC_ENDPOINT_TS,
endpoint,
forcePathStyle: true,
credentials: {
accessKeyId: env.MINIO_ACCESS_KEY_ID,
secretAccessKey: env.MINIO_SECRET_ACCESS_KEY
}
secretAccessKey: env.MINIO_SECRET_ACCESS_KEY,
},
});
return cachedPublic;
if (!override) {
cachedPublic = client;
}
return client;
}
export async function presignGetObjectUrl(input: {
@@ -80,9 +66,10 @@ export async function presignGetObjectUrl(input: {
expiresSeconds?: number;
responseContentType?: string;
responseContentDisposition?: string;
endpoint?: PresignEndpointOverride;
}) {
const env = getMinioEnv();
const s3 = getMinioPublicSigningClient();
const s3 = getMinioPublicSigningClient(input.endpoint);
const command = new GetObjectCommand({
Bucket: input.bucket,
+20 -2
View File
@@ -11,7 +11,8 @@ const envSchema = z.object({
export const jobNameSchema = z.enum([
"scan_minio_prefix",
"process_asset",
"copy_to_canonical"
"copy_to_canonical",
"transcode_video_mp4"
]);
export type QueueJobName = z.infer<typeof jobNameSchema>;
@@ -36,15 +37,23 @@ export const copyToCanonicalPayloadSchema = z
})
.strict();
export const transcodeVideoMp4PayloadSchema = z
.object({
assetId: z.string().uuid()
})
.strict();
export const payloadByJobNameSchema = z.discriminatedUnion("name", [
z.object({ name: z.literal("scan_minio_prefix"), payload: scanMinioPrefixPayloadSchema }),
z.object({ name: z.literal("process_asset"), payload: processAssetPayloadSchema }),
z.object({ name: z.literal("copy_to_canonical"), payload: copyToCanonicalPayloadSchema })
z.object({ name: z.literal("copy_to_canonical"), payload: copyToCanonicalPayloadSchema }),
z.object({ name: z.literal("transcode_video_mp4"), payload: transcodeVideoMp4PayloadSchema })
]);
export type ScanMinioPrefixPayload = z.infer<typeof scanMinioPrefixPayloadSchema>;
export type ProcessAssetPayload = z.infer<typeof processAssetPayloadSchema>;
export type CopyToCanonicalPayload = z.infer<typeof copyToCanonicalPayloadSchema>;
export type TranscodeVideoMp4Payload = z.infer<typeof transcodeVideoMp4PayloadSchema>;
type QueueEnv = z.infer<typeof envSchema>;
@@ -126,3 +135,12 @@ export async function enqueueCopyToCanonical(input: CopyToCanonicalPayload) {
backoff: { type: "exponential", delay: 1000 }
});
}
export async function enqueueTranscodeVideoMp4(input: TranscodeVideoMp4Payload) {
const payload = transcodeVideoMp4PayloadSchema.parse(input);
const queue = getQueue();
return queue.add("transcode_video_mp4", payload, {
attempts: 3,
backoff: { type: "exponential", delay: 1000 }
});
}