diff --git a/.gitea/workflows/build-images.yml b/.gitea/workflows/build-images.yml new file mode 100644 index 0000000..342f050 --- /dev/null +++ b/.gitea/workflows/build-images.yml @@ -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 diff --git a/README.md b/README.md index 1555430..49a9609 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # porthole +[![Build Status](/repos/will/porthole/badge.svg?branch=main)](/repos/will/porthole/actions) + Porthole: timeline media library (Next.js web + worker), backed by Postgres/Redis/MinIO. ## How to try it @@ -84,6 +86,21 @@ spec: This repo is a Bun monorepo, but container builds use Docker Buildx. +### CI/CD (Automated) + +The repository includes a Gitea Actions workflow (`.gitea/workflows/build-images.yml`) that automatically: +- Runs `bun run typecheck` on every push and PR +- Runs `bash run_tests.sh` (Go tests) to keep the repo green +- Builds and pushes multi-arch images (`linux/amd64`, `linux/arm64`) for `apps/web` and `apps/worker` +- Pushes to `gitea-gitea-http.taildb3494.ts.net/will/porthole-web` and `.../porthole-worker` + +Images are tagged with: +- Branch name (e.g., `main`, `feature/my-branch`) +- Git SHA (short format) +- Semantic version (when tags like `v1.2.3` are pushed) + +### Manual Build + - Assumptions: - You have an **in-cluster registry** reachable over **insecure HTTP** (example: `registry.lan:5000`). - Your Docker daemon is configured to allow that registry as an insecure registry. diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index c65372a..d3c83f8 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -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(null); + + const [tags, setTags] = useState([]); + const [tagsError, setTagsError] = useState(null); + const [tagsLoading, setTagsLoading] = useState(false); + const [newTag, setNewTag] = useState(""); + + const [albums, setAlbums] = useState([]); + const [albumsError, setAlbumsError] = useState(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 ( -
-

Admin

-

Upload + scan tools will live here.

+
+
+

Admin

+

+ Manage tags and albums. Admin token is stored in sessionStorage. +

+
+ +
+

Admin Token

+
+ setTokenInput(e.target.value)} + style={{ padding: 8, borderRadius: 6, border: "1px solid #ccc" }} + /> +
+ + +
+ {tokenMessage ? ( +
{tokenMessage}
+ ) : null} +
+
+ +
+

Tags

+
+ setNewTag(e.target.value)} + style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }} + /> + + +
+ {tagsError ? ( +
{tagsError}
+ ) : null} +
    + {tags.length === 0 ? ( +
  • No tags yet.
  • + ) : ( + tags.map((tag) =>
  • {tag.name}
  • ) + )} +
+
+ +
+

Albums

+
+ setNewAlbum(e.target.value)} + style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }} + /> + + +
+ {albumsError ? ( +
{albumsError}
+ ) : null} +
    + {albums.length === 0 ? ( +
  • No albums yet.
  • + ) : ( + albums.map((album) =>
  • {album.name}
  • ) + )} +
+
); } diff --git a/apps/web/app/api/albums/[id]/assets/route.ts b/apps/web/app/api/albums/[id]/assets/route.ts new file mode 100644 index 0000000..5d4b0c6 --- /dev/null +++ b/apps/web/app/api/albums/[id]/assets/route.ts @@ -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 { + 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 { + 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 }); +} diff --git a/apps/web/app/api/albums/handlers.ts b/apps/web/app/api/albums/handlers.ts new file mode 100644 index 0000000..a98fd62 --- /dev/null +++ b/apps/web/app/api/albums/handlers.ts @@ -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; + +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 } }; +} diff --git a/apps/web/app/api/albums/route.ts b/apps/web/app/api/albums/route.ts new file mode 100644 index 0000000..32ca86e --- /dev/null +++ b/apps/web/app/api/albums/route.ts @@ -0,0 +1,17 @@ +import { getAdminOk, handleCreateAlbum, handleListAlbums } from "./handlers"; + +export const runtime = "nodejs"; + +export async function GET(request: Request): Promise { + const res = await handleListAlbums({ adminOk: getAdminOk(request.headers) }); + return Response.json(res.body, { status: res.status }); +} + +export async function POST(request: Request): Promise { + const bodyJson = await request.json().catch(() => ({})); + const res = await handleCreateAlbum({ + adminOk: getAdminOk(request.headers), + body: bodyJson, + }); + return Response.json(res.body, { status: res.status }); +} diff --git a/apps/web/app/api/assets/[id]/dupes/handlers.ts b/apps/web/app/api/assets/[id]/dupes/handlers.ts new file mode 100644 index 0000000..b9c03a4 --- /dev/null +++ b/apps/web/app/api/assets/[id]/dupes/handlers.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +import { getDb } from "@tline/db"; + +const paramsSchema = z.object({ id: z.string().uuid() }); + +type DbLike = ReturnType; + +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 } }; +} diff --git a/apps/web/app/api/assets/[id]/dupes/route.ts b/apps/web/app/api/assets/[id]/dupes/route.ts new file mode 100644 index 0000000..07b2a07 --- /dev/null +++ b/apps/web/app/api/assets/[id]/dupes/route.ts @@ -0,0 +1,12 @@ +import { handleGetDupes } from "./handlers"; + +export const runtime = "nodejs"; + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +): Promise { + const rawParams = await context.params; + const result = await handleGetDupes({ params: rawParams }); + return Response.json(result.body, { status: result.status }); +} diff --git a/apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts b/apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts new file mode 100644 index 0000000..a4420e0 --- /dev/null +++ b/apps/web/app/api/assets/[id]/override-capture-ts/handlers.ts @@ -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; + +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, + }, + }; +} diff --git a/apps/web/app/api/assets/[id]/override-capture-ts/route.ts b/apps/web/app/api/assets/[id]/override-capture-ts/route.ts new file mode 100644 index 0000000..4dffd05 --- /dev/null +++ b/apps/web/app/api/assets/[id]/override-capture-ts/route.ts @@ -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 { + 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 }); +} diff --git a/apps/web/app/api/assets/[id]/tags/route.ts b/apps/web/app/api/assets/[id]/tags/route.ts new file mode 100644 index 0000000..ac51a00 --- /dev/null +++ b/apps/web/app/api/assets/[id]/tags/route.ts @@ -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 { + 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 }); +} diff --git a/apps/web/app/api/assets/[id]/url/route.ts b/apps/web/app/api/assets/[id]/url/route.ts index f50afb2..d0d4d0f 100644 --- a/apps/web/app/api/assets/[id]/url/route.ts +++ b/apps/web/app/api/assets/[id]/url/route.ts @@ -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 = "original"; + let requestedSize: number | null = null; + let legacyVariant: z.infer | 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, { diff --git a/apps/web/app/api/assets/[id]/url/variant.ts b/apps/web/app/api/assets/[id]/url/variant.ts new file mode 100644 index 0000000..645357e --- /dev/null +++ b/apps/web/app/api/assets/[id]/url/variant.ts @@ -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; +} diff --git a/apps/web/app/api/assets/[id]/variants/route.ts b/apps/web/app/api/assets/[id]/variants/route.ts new file mode 100644 index 0000000..80f9207 --- /dev/null +++ b/apps/web/app/api/assets/[id]/variants/route.ts @@ -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 { + 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)); +} diff --git a/apps/web/app/api/assets/[id]/variants/shape.ts b/apps/web/app/api/assets/[id]/variants/shape.ts new file mode 100644 index 0000000..a44613f --- /dev/null +++ b/apps/web/app/api/assets/[id]/variants/shape.ts @@ -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, + })); +} diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts index 2bc75b6..5fd3538 100644 --- a/apps/web/app/api/assets/route.ts +++ b/apps/web/app/api/assets/route.ts @@ -68,35 +68,37 @@ export async function GET(request: Request): Promise { }[] >` 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} `; diff --git a/apps/web/app/api/geo/route.ts b/apps/web/app/api/geo/route.ts new file mode 100644 index 0000000..114023e --- /dev/null +++ b/apps/web/app/api/geo/route.ts @@ -0,0 +1,29 @@ +import { getDb } from "@tline/db"; + +import { shapeGeoRows } from "./shape"; + +export const runtime = "nodejs"; + +export async function GET(): Promise { + 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)); +} diff --git a/apps/web/app/api/geo/shape.ts b/apps/web/app/api/geo/shape.ts new file mode 100644 index 0000000..bd2633e --- /dev/null +++ b/apps/web/app/api/geo/shape.ts @@ -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, + })); +} diff --git a/apps/web/app/api/imports/[id]/scan-minio/route.ts b/apps/web/app/api/imports/[id]/scan-minio/route.ts index a83521c..1dfe340 100644 --- a/apps/web/app/api/imports/[id]/scan-minio/route.ts +++ b/apps/web/app/api/imports/[id]/scan-minio/route.ts @@ -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 { 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 }); } diff --git a/apps/web/app/api/imports/[id]/upload/route.ts b/apps/web/app/api/imports/[id]/upload/route.ts index d7210a0..4fbaf40 100644 --- a/apps/web/app/api/imports/[id]/upload/route.ts +++ b/apps/web/app/api/imports/[id]/upload/route.ts @@ -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 { 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 }); } diff --git a/apps/web/app/api/imports/handlers.ts b/apps/web/app/api/imports/handlers.ts new file mode 100644 index 0000000..42bd690 --- /dev/null +++ b/apps/web/app/api/imports/handlers.ts @@ -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 }, + }; +} diff --git a/apps/web/app/api/imports/route.ts b/apps/web/app/api/imports/route.ts index f1fb1c1..01895e5 100644 --- a/apps/web/app/api/imports/route.ts +++ b/apps/web/app/api/imports/route.ts @@ -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 { 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 }); } diff --git a/apps/web/app/api/moments/route.ts b/apps/web/app/api/moments/route.ts new file mode 100644 index 0000000..0604655 --- /dev/null +++ b/apps/web/app/api/moments/route.ts @@ -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 { + 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, + }); +} diff --git a/apps/web/app/api/tags/handlers.ts b/apps/web/app/api/tags/handlers.ts new file mode 100644 index 0000000..7a81e5c --- /dev/null +++ b/apps/web/app/api/tags/handlers.ts @@ -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; + +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 }; +} diff --git a/apps/web/app/api/tags/route.ts b/apps/web/app/api/tags/route.ts new file mode 100644 index 0000000..b65275c --- /dev/null +++ b/apps/web/app/api/tags/route.ts @@ -0,0 +1,17 @@ +import { getAdminOk, handleCreateTag, handleListTags } from "./handlers"; + +export const runtime = "nodejs"; + +export async function GET(request: Request): Promise { + const res = await handleListTags({ adminOk: getAdminOk(request.headers) }); + return Response.json(res.body, { status: res.status }); +} + +export async function POST(request: Request): Promise { + const bodyJson = await request.json().catch(() => ({})); + const res = await handleCreateTag({ + adminOk: getAdminOk(request.headers), + body: bodyJson, + }); + return Response.json(res.body, { status: res.status }); +} diff --git a/apps/web/app/api/tree/route.ts b/apps/web/app/api/tree/route.ts index 660f624..f8fd6e5 100644 --- a/apps/web/app/api/tree/route.ts +++ b/apps/web/app/api/tree/route.ts @@ -18,7 +18,7 @@ const querySchema = z type Granularity = z.infer["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 { >` 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 { 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 diff --git a/apps/web/app/components/MediaPanel.tsx b/apps/web/app/components/MediaPanel.tsx index da39362..3da05b9 100644 --- a/apps/web/app/components/MediaPanel.tsx +++ b/apps/web/app/components/MediaPanel.tsx @@ -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; +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(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([]); + const [albums, setAlbums] = useState([]); + const [tagId, setTagId] = useState(""); + const [albumId, setAlbumId] = useState(""); + const [adminError, setAdminError] = useState(null); + const [adminBusy, setAdminBusy] = useState(false); + const [overrideInput, setOverrideInput] = useState(""); + const [overrideError, setOverrideError] = useState(null); + const [overrideBusy, setOverrideBusy] = useState(false); + const [baseCaptureTs, setBaseCaptureTs] = useState(null); + const [dupes, setDupes] = useState | null>(null); + const [dupesError, setDupesError] = useState(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 }) {
{viewer.asset.id}
+ +
+ Duplicates + {dupesError ? ( +
+ {dupesError} +
+ ) : null} + {dupes === null ? ( +
+ Loading... +
+ ) : dupes.length === 0 ? ( +
+ No duplicates. +
+ ) : ( +
+
{dupes.length} duplicate(s)
+ {dupes.map((dupe) => ( +
{dupe.id.slice(0, 8)}
+ ))} +
+ )} +
+ +
+ Capture time override +
+ Effective: {viewer.asset.capture_ts_utc ?? "(unset)"} +
+
+ Base: {baseCaptureTs ?? "(unknown)"} +
+
+ + setOverrideInput(e.target.value)} + style={{ padding: 6, borderRadius: 6, border: "1px solid #ccc" }} + disabled={overrideBusy} + /> +
+ + +
+ {overrideError ? ( +
+ {overrideError} +
+ ) : null} +
+
+ +
+ Tags & Albums + {adminError ? ( +
+ {adminError} +
+ ) : null} +
+ +
+ + +
+
+
+ +
+ + +
+
+
) : (
diff --git a/apps/web/app/components/TimelineTree.tsx b/apps/web/app/components/TimelineTree.tsx index 5f594cd..fee8267 100644 --- a/apps/web/app/components/TimelineTree.tsx +++ b/apps/web/app/components/TimelineTree.tsx @@ -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; @@ -147,6 +158,9 @@ export function TimelineTree(props: { const [expanded, setExpanded] = useState({}); const [rows, setRows] = useState(null); const [error, setError] = useState(null); + const [showMoments, setShowMoments] = useState(false); + const [moments, setMoments] = useState(null); + const [momentsError, setMomentsError] = useState(null); // simple pan/zoom via viewBox const svgRef = useRef(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 + {rows ? ( {rows.length} day nodes ) : null}
{error ?
Error: {error}
: null} + {momentsError ? ( +
Moments error: {momentsError}
+ ) : null} {!rows && !error ? (
c.day === dayKey) ?? [] + : []; + const momentsCount = dayMoments.reduce( + (sum, c) => sum + c.count, + 0, + ); + return ( {node.label} ({node.countReady}/{node.countTotal}) {hasChildren ? (isExpanded ? " ▼" : " ▶") : ""} + {showMoments && isDay ? ` · ${momentsCount} moment assets` : ""} ); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 1bf0312..b815395 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { getAppName } from "@tline/config"; +import "leaflet/dist/leaflet.css"; export const metadata = { title: getAppName() diff --git a/apps/web/app/lib/moments.ts b/apps/web/app/lib/moments.ts new file mode 100644 index 0000000..755d6ef --- /dev/null +++ b/apps/web/app/lib/moments.ts @@ -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(); + + 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; +} diff --git a/apps/web/app/lib/playback.ts b/apps/web/app/lib/playback.ts new file mode 100644 index 0000000..df9375e --- /dev/null +++ b/apps/web/app/lib/playback.ts @@ -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; +} diff --git a/apps/web/app/map/page.tsx b/apps/web/app/map/page.tsx new file mode 100644 index 0000000..949472b --- /dev/null +++ b/apps/web/app/map/page.tsx @@ -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([]); + + 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([]); + const [error, setError] = useState(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 ( +
+
+

Map

+
+ + {loading ? ( +
+ Loading map... +
+ ) : error ? ( +
+ Error: {error} +
+ ) : points.length === 0 ? ( +
+ No GPS points available +
+ ) : ( +
+ + + + +
+ )} +
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index eb51fa1..767df17 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -16,6 +16,9 @@ export default function HomePage() {

{getAppName()}

    +
  • + Map +
  • Admin
  • diff --git a/apps/web/src/__tests__/admin-gates-imports.test.ts b/apps/web/src/__tests__/admin-gates-imports.test.ts new file mode 100644 index 0000000..5f1c834 --- /dev/null +++ b/apps/web/src/__tests__/admin-gates-imports.test.ts @@ -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" }); +}); diff --git a/apps/web/src/__tests__/albums-admin-auth.test.ts b/apps/web/src/__tests__/albums-admin-auth.test.ts new file mode 100644 index 0000000..7d32f98 --- /dev/null +++ b/apps/web/src/__tests__/albums-admin-auth.test.ts @@ -0,0 +1,161 @@ +import { test, expect } from "bun:test"; + +function createMockDb(responses: Array) { + const calls: Array<{ sql: string; values: unknown[] }> = []; + const db = async (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, + ); +}); diff --git a/apps/web/src/__tests__/asset-overrides-admin-auth.test.ts b/apps/web/src/__tests__/asset-overrides-admin-auth.test.ts new file mode 100644 index 0000000..eaf1f33 --- /dev/null +++ b/apps/web/src/__tests__/asset-overrides-admin-auth.test.ts @@ -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 ( + strings: TemplateStringsArray, + ...values: unknown[] + ): Promise => { + 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; +}; + +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, + }); +}); diff --git a/apps/web/src/__tests__/dupes-route.test.ts b/apps/web/src/__tests__/dupes-route.test.ts new file mode 100644 index 0000000..e4139ea --- /dev/null +++ b/apps/web/src/__tests__/dupes-route.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/__tests__/geo-route.test.ts b/apps/web/src/__tests__/geo-route.test.ts new file mode 100644 index 0000000..c6d39a8 --- /dev/null +++ b/apps/web/src/__tests__/geo-route.test.ts @@ -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 }, + ]); +}); diff --git a/apps/web/src/__tests__/moments.test.ts b/apps/web/src/__tests__/moments.test.ts new file mode 100644 index 0000000..c9c1e63 --- /dev/null +++ b/apps/web/src/__tests__/moments.test.ts @@ -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"); +}); diff --git a/apps/web/src/__tests__/prefer-derived.test.ts b/apps/web/src/__tests__/prefer-derived.test.ts new file mode 100644 index 0000000..dc41bd8 --- /dev/null +++ b/apps/web/src/__tests__/prefer-derived.test.ts @@ -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(); +}); diff --git a/apps/web/src/__tests__/smoke.test.ts b/apps/web/src/__tests__/smoke.test.ts new file mode 100644 index 0000000..8f96637 --- /dev/null +++ b/apps/web/src/__tests__/smoke.test.ts @@ -0,0 +1,3 @@ +import { expect, test } from "bun:test"; + +test("bun test runs", () => expect(1 + 1).toBe(2)); diff --git a/apps/web/src/__tests__/tags-admin-auth.test.ts b/apps/web/src/__tests__/tags-admin-auth.test.ts new file mode 100644 index 0000000..e74c438 --- /dev/null +++ b/apps/web/src/__tests__/tags-admin-auth.test.ts @@ -0,0 +1,83 @@ +import { test, expect } from "bun:test"; + +function createMockDb(responses: Array) { + const calls: Array<{ sql: string; values: unknown[] }> = []; + const db = async (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); +}); diff --git a/apps/web/src/__tests__/variant-url-404.test.ts b/apps/web/src/__tests__/variant-url-404.test.ts new file mode 100644 index 0000000..17b66d9 --- /dev/null +++ b/apps/web/src/__tests__/variant-url-404.test.ts @@ -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(); +}); diff --git a/apps/web/src/__tests__/variants-route.test.ts b/apps/web/src/__tests__/variants-route.test.ts new file mode 100644 index 0000000..8d9d727 --- /dev/null +++ b/apps/web/src/__tests__/variants-route.test.ts @@ -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" }, + ]); +}); diff --git a/apps/worker/src/__tests__/transcode-plan.test.ts b/apps/worker/src/__tests__/transcode-plan.test.ts new file mode 100644 index 0000000..c6c1d55 --- /dev/null +++ b/apps/worker/src/__tests__/transcode-plan.test.ts @@ -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); +}); diff --git a/apps/worker/src/__tests__/variants-sizes.test.ts b/apps/worker/src/__tests__/variants-sizes.test.ts new file mode 100644 index 0000000..7940f0d --- /dev/null +++ b/apps/worker/src/__tests__/variants-sizes.test.ts @@ -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); +}); diff --git a/apps/worker/src/hash-utils.ts b/apps/worker/src/hash-utils.ts new file mode 100644 index 0000000..f99bff8 --- /dev/null +++ b/apps/worker/src/hash-utils.ts @@ -0,0 +1,12 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; + +export async function computeFileSha256(filePath: string): Promise { + 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"))); + }); +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 7f8bfed..729fac8 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -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}`); }, diff --git a/apps/worker/src/jobs.ts b/apps/worker/src/jobs.ts index 858b973..c6e88ae 100644 --- a/apps/worker/src/jobs.ts +++ b/apps/worker/src/jobs.ts @@ -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 { 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; + 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) { + 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 = { + const sha256 = await computeFileSha256(inputPath); + await upsertAssetHash({ assetId: asset.id, bucket: asset.bucket, sha256 }); + + const updates: Record = { 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 = {}; 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 = {}; + 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 = {}; + 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); diff --git a/apps/worker/src/transcode.ts b/apps/worker/src/transcode.ts new file mode 100644 index 0000000..0edd549 --- /dev/null +++ b/apps/worker/src/transcode.ts @@ -0,0 +1,3 @@ +export function shouldTranscodeToMp4(input: { mimeType: string }) { + return input.mimeType !== "video/mp4"; +} diff --git a/apps/worker/src/variants.ts b/apps/worker/src/variants.ts new file mode 100644 index 0000000..4559dc5 --- /dev/null +++ b/apps/worker/src/variants.ts @@ -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 }, + ]; +} diff --git a/bun.lock b/bun.lock index eed8bb3..15356bc 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docs/plans/2026-02-01-use-playback-selector.md b/docs/plans/2026-02-01-use-playback-selector.md new file mode 100644 index 0000000..55d59dc --- /dev/null +++ b/docs/plans/2026-02-01-use-playback-selector.md @@ -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" +``` diff --git a/docs/plans/2026-02-02-tags-albums-ui.md b/docs/plans/2026-02-02-tags-albums-ui.md new file mode 100644 index 0000000..88dbb59 --- /dev/null +++ b/docs/plans/2026-02-02-tags-albums-ui.md @@ -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" +``` diff --git a/docs/plans/2026-02-03-capture-time-override-ui.md b/docs/plans/2026-02-03-capture-time-override-ui.md new file mode 100644 index 0000000..f1def8e --- /dev/null +++ b/docs/plans/2026-02-03-capture-time-override-ui.md @@ -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(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 +
    + Capture time override +
    + Effective: {effectiveTs ?? "(none)"} +
    +
    + Base: {baseTs ?? "(unknown)"} +
    +
    + setCaptureOverrideInput(e.target.value)} + style={{ flex: 1, padding: 6 }} + disabled={captureOverrideBusy} + /> + +
    + {captureOverrideError ? ( +
    {captureOverrideError}
    + ) : null} +
    +``` + +**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" +``` diff --git a/helm/porthole/templates/job-apply-lifecycle.yaml.tpl b/helm/porthole/templates/job-apply-lifecycle.yaml.tpl new file mode 100644 index 0000000..0aecd23 --- /dev/null +++ b/helm/porthole/templates/job-apply-lifecycle.yaml.tpl @@ -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 }} diff --git a/helm/porthole/values.yaml b/helm/porthole/values.yaml index e9c3d4b..716bf5e 100644 --- a/helm/porthole/values.yaml +++ b/helm/porthole/values.yaml @@ -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: diff --git a/package.json b/package.json index b4d6506..c375263 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/config/src/adminAuth.test.ts b/packages/config/src/adminAuth.test.ts new file mode 100644 index 0000000..36ea26c --- /dev/null +++ b/packages/config/src/adminAuth.test.ts @@ -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); +}); diff --git a/packages/config/src/adminAuth.ts b/packages/config/src/adminAuth.ts new file mode 100644 index 0000000..42ccabf --- /dev/null +++ b/packages/config/src/adminAuth.ts @@ -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; +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 7aebbc7..1904f33 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -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 | 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; +} diff --git a/packages/db/migrations/0003_asset_variants.sql b/packages/db/migrations/0003_asset_variants.sql new file mode 100644 index 0000000..d5a4d42 --- /dev/null +++ b/packages/db/migrations/0003_asset_variants.sql @@ -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); diff --git a/packages/db/migrations/0004_tags_albums_audit.sql b/packages/db/migrations/0004_tags_albums_audit.sql new file mode 100644 index 0000000..7b8bf26 --- /dev/null +++ b/packages/db/migrations/0004_tags_albums_audit.sql @@ -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() +); diff --git a/packages/db/migrations/0005_asset_overrides.sql b/packages/db/migrations/0005_asset_overrides.sql new file mode 100644 index 0000000..d7fd0e6 --- /dev/null +++ b/packages/db/migrations/0005_asset_overrides.sql @@ -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); diff --git a/packages/db/migrations/0006_assets_gps.sql b/packages/db/migrations/0006_assets_gps.sql new file mode 100644 index 0000000..0994c0c --- /dev/null +++ b/packages/db/migrations/0006_assets_gps.sql @@ -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; diff --git a/packages/db/migrations/0007_asset_hashes.sql b/packages/db/migrations/0007_asset_hashes.sql new file mode 100644 index 0000000..1fb79eb --- /dev/null +++ b/packages/db/migrations/0007_asset_hashes.sql @@ -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; diff --git a/packages/minio/src/endpointSelector.test.ts b/packages/minio/src/endpointSelector.test.ts new file mode 100644 index 0000000..183d379 --- /dev/null +++ b/packages/minio/src/endpointSelector.test.ts @@ -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", + ); +}); diff --git a/packages/minio/src/endpointSelector.ts b/packages/minio/src/endpointSelector.ts new file mode 100644 index 0000000..25fe692 --- /dev/null +++ b/packages/minio/src/endpointSelector.ts @@ -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; +} diff --git a/packages/minio/src/env.ts b/packages/minio/src/env.ts new file mode 100644 index 0000000..16d1673 --- /dev/null +++ b/packages/minio/src/env.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; + +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; +} diff --git a/packages/minio/src/index.ts b/packages/minio/src/index.ts index a85331b..639faa5 100644 --- a/packages/minio/src/index.ts +++ b/packages/minio/src/index.ts @@ -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; - -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, diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index f969488..408a676 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -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; @@ -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; export type ProcessAssetPayload = z.infer; export type CopyToCanonicalPayload = z.infer; +export type TranscodeVideoMp4Payload = z.infer; type QueueEnv = z.infer; @@ -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 } + }); +}