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:
@@ -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
|
||||
@@ -1,5 +1,7 @@
|
||||
# porthole
|
||||
|
||||
[](/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
@@ -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 });
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { getAppName } from "@tline/config";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
export const metadata = {
|
||||
title: getAppName()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shouldTranscodeToMp4(input: { mimeType: string }) {
|
||||
return input.mimeType !== "video/mp4";
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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 }}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user