Compare commits

..

3 Commits

Author SHA1 Message Date
OpenCode Test 901986f84f feat: enable web, worker, and migrate jobs with correct registry 2025-12-26 13:46:24 -08:00
OpenCode Test f7b93dc284 fix: add .worktrees/ to .gitignore to prevent tracking worktree contents 2025-12-26 12:44:41 -08:00
OpenCode Test 360998d064 docs: add plan to enable web and worker applications 2025-12-26 12:41:36 -08:00
78 changed files with 1135 additions and 4849 deletions
-152
View File
@@ -1,152 +0,0 @@
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
+3 -10
View File
@@ -16,16 +16,6 @@ controltower
*.so
*.dylib
# Git worktrees (local)
.worktrees/
worktrees/
# TypeScript incremental build info
*.tsbuildinfo
# Local scratch files
.tmp-*
# Test binary
*.test
@@ -51,3 +41,6 @@ Thumbs.db
# Temporary files
CHANGES_SUMMARY.sh
# Git worktrees
.worktrees/
+663 -2
View File
@@ -1,2 +1,663 @@
# Temporary file used during local helm rendering.
# This file is intentionally empty in-repo; real rendered output should not be committed.
---
# Source: tline/templates/secret.yaml.tpl
apiVersion: v1
kind: Secret
metadata:
name: tline-tline-secrets
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
type: Opaque
data:
POSTGRES_PASSWORD: Y2hhbmdlLW1l
MINIO_ACCESS_KEY_ID: bWluaW9hZG1pbg==
MINIO_SECRET_ACCESS_KEY: bWluaW9hZG1pbg==---
apiVersion: v1
kind: Secret
metadata:
name: tline-tline-registry
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: eyJhdXRocyI6eyJyZWdpc3RyeS5sYW46NTAwMCI6eyJhdXRoIjoiZFRwdyIsImVtYWlsIjoiZUBleGFtcGxlLmNvbSIsInBhc3N3b3JkIjoicCIsInVzZXJuYW1lIjoidSJ9fX0=
---
# Source: tline/templates/configmap.yaml.tpl
apiVersion: v1
kind: ConfigMap
metadata:
name: tline-tline-config
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
data:
APP_NAME: "flux"
NEXT_PUBLIC_APP_NAME: "flux"
QUEUE_NAME: "tline"
DATABASE_URL: "postgres://tline:change-me@tline-tline-postgres:5432/tline"
REDIS_URL: "redis://tline-tline-redis:6379"
MINIO_INTERNAL_ENDPOINT: "http://tline-tline-minio:9000"
MINIO_PUBLIC_ENDPOINT_TS: "https://minio.tailxyz.ts.net"
MINIO_REGION: "us-east-1"
MINIO_BUCKET: "media"
MINIO_PRESIGN_EXPIRES_SECONDS: "900"
---
# Source: tline/templates/minio.yaml.tpl
apiVersion: v1
kind: Service
metadata:
name: tline-tline-minio
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: minio
spec:
type: ClusterIP
ports:
- name: s3
port: 9000
targetPort: s3
- name: console
port: 9001
targetPort: console
selector:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: minio
---
# Source: tline/templates/postgres.yaml.tpl
apiVersion: v1
kind: Service
metadata:
name: tline-tline-postgres
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
spec:
type: ClusterIP
ports:
- name: postgres
port: 5432
targetPort: postgres
selector:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: postgres
---
# Source: tline/templates/redis.yaml.tpl
apiVersion: v1
kind: Service
metadata:
name: tline-tline-redis
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
spec:
type: ClusterIP
ports:
- name: redis
port: 6379
targetPort: redis
selector:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: redis
---
# Source: tline/templates/web.yaml.tpl
apiVersion: v1
kind: Service
metadata:
name: tline-tline-web
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: web
spec:
type: ClusterIP
ports:
- name: http
port: 3000
targetPort: http
selector:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: web
---
# Source: tline/templates/redis.yaml.tpl
apiVersion: apps/v1
kind: Deployment
metadata:
name: tline-tline-redis
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: redis
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: redis
template:
metadata:
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: redis
spec:
imagePullSecrets:
- name: "tline-tline-registry"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-class
operator: In
values:
- compute
containers:
- name: redis
image: "redis:7"
imagePullPolicy: IfNotPresent
ports:
- name: redis
containerPort: 6379
resources:
limits:
cpu: 300m
memory: 512Mi
requests:
cpu: 50m
memory: 128Mi
---
# Source: tline/templates/web.yaml.tpl
apiVersion: apps/v1
kind: Deployment
metadata:
name: tline-tline-web
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: web
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: web
template:
metadata:
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: web
spec:
imagePullSecrets:
- name: "tline-tline-registry"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-class
operator: In
values:
- compute
containers:
- name: web
image: "registry.lan:5000/tline-web:dev"
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3000
envFrom:
- configMapRef:
name: tline-tline-config
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: tline-tline-secrets
key: POSTGRES_PASSWORD
- name: MINIO_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: tline-tline-secrets
key: MINIO_ACCESS_KEY_ID
- name: MINIO_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: tline-tline-secrets
key: MINIO_SECRET_ACCESS_KEY
readinessProbe:
httpGet:
path: /api/healthz
port: http
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /api/healthz
port: http
initialDelaySeconds: 20
periodSeconds: 10
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 200m
memory: 256Mi
---
# Source: tline/templates/worker.yaml.tpl
apiVersion: apps/v1
kind: Deployment
metadata:
name: tline-tline-worker
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: worker
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: worker
template:
metadata:
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: worker
spec:
imagePullSecrets:
- name: "tline-tline-registry"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-class
operator: In
values:
- compute
containers:
- name: worker
image: "registry.lan:5000/tline-worker:dev"
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
name: tline-tline-config
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: tline-tline-secrets
key: POSTGRES_PASSWORD
- name: MINIO_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: tline-tline-secrets
key: MINIO_ACCESS_KEY_ID
- name: MINIO_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: tline-tline-secrets
key: MINIO_SECRET_ACCESS_KEY
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
---
# Source: tline/templates/minio.yaml.tpl
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: tline-tline-minio
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: minio
spec:
serviceName: tline-tline-minio
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: minio
template:
metadata:
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: minio
spec:
imagePullSecrets:
- name: "tline-tline-registry"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-class
operator: In
values:
- compute
containers:
- name: minio
image: "minio/minio:RELEASE.2024-01-16T16-07-38Z"
imagePullPolicy: IfNotPresent
args:
- server
- /data
- "--console-address=:9001"
ports:
- name: s3
containerPort: 9000
- name: console
containerPort: 9001
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: tline-tline-secrets
key: MINIO_ACCESS_KEY_ID
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: tline-tline-secrets
key: MINIO_SECRET_ACCESS_KEY
readinessProbe:
httpGet:
path: /minio/health/ready
port: s3
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /minio/health/live
port: s3
initialDelaySeconds: 20
periodSeconds: 10
resources:
limits:
cpu: 1500m
memory: 2Gi
requests:
cpu: 250m
memory: 512Mi
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: "200Gi"
---
# Source: tline/templates/postgres.yaml.tpl
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: tline-tline-postgres
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: postgres
spec:
serviceName: tline-tline-postgres
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: postgres
template:
metadata:
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: postgres
spec:
imagePullSecrets:
- name: "tline-tline-registry"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-class
operator: In
values:
- compute
containers:
- name: postgres
image: "postgres:16"
imagePullPolicy: IfNotPresent
ports:
- name: postgres
containerPort: 5432
env:
- name: POSTGRES_USER
value: "tline"
- name: POSTGRES_DB
value: "tline"
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: tline-tline-secrets
key: POSTGRES_PASSWORD
readinessProbe:
exec:
command:
- sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command:
- sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 20
periodSeconds: 10
resources:
limits:
cpu: 1500m
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: "20Gi"
---
# Source: tline/templates/ingress-tailscale.yaml.tpl
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tline-tline-web
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: web
annotations:
spec:
ingressClassName: tailscale
tls:
- hosts:
- "app"
rules:
- host: "app"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tline-tline-web
port:
number: 3000
---
# Source: tline/templates/ingress-tailscale.yaml.tpl
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tline-tline-minio
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: minio
annotations:
spec:
ingressClassName: tailscale
tls:
- hosts:
- "minio"
rules:
- host: "minio"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tline-tline-minio
port:
number: 9000
---
# Source: tline/templates/ingress-tailscale.yaml.tpl
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tline-tline-minio-console
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: minio
annotations:
spec:
ingressClassName: tailscale
tls:
- hosts:
- "minio-console"
rules:
- host: "minio-console"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tline-tline-minio
port:
number: 9001
---
# Source: tline/templates/job-migrate.yaml.tpl
apiVersion: batch/v1
kind: Job
metadata:
name: tline-tline-migrate
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/managed-by: Helm
helm.sh/chart: "tline-0.1.0"
app.kubernetes.io/component: migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-10"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
template:
metadata:
labels:
app.kubernetes.io/name: tline
app.kubernetes.io/instance: tline
app.kubernetes.io/component: migrate
spec:
restartPolicy: Never
imagePullSecrets:
- name: "tline-tline-registry"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-class
operator: In
values:
- compute
containers:
- name: migrate
image: "registry.lan:5000/tline-worker:dev"
imagePullPolicy: IfNotPresent
command:
- bun
- run
- packages/db/src/migrate.ts
envFrom:
- configMapRef:
name: tline-tline-config
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: tline-tline-secrets
key: POSTGRES_PASSWORD
+25 -2
View File
@@ -1,2 +1,25 @@
# Temporary file used during local helm rendering.
# This file is intentionally empty in-repo; real values should not be committed.
secrets:
postgres:
password: "change-me"
minio:
accessKeyId: "minioadmin"
secretAccessKey: "minioadmin"
images:
web:
repository: registry.lan:5000/tline-web
tag: dev
worker:
repository: registry.lan:5000/tline-worker
tag: dev
global:
tailscale:
tailnetFQDN: "tailxyz.ts.net"
registrySecret:
create: true
server: "registry.lan:5000"
username: "u"
password: "p"
email: "e@example.com"
-17
View File
@@ -1,7 +1,5 @@
# 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
@@ -86,21 +84,6 @@ 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.
+3 -248
View File
@@ -1,253 +1,8 @@
"use client";
import { useEffect, useMemo, useState } from "react";
const ADMIN_TOKEN_KEY = "porthole_admin_token";
type Tag = {
id: string;
name: string;
created_at: string;
};
type Album = {
id: string;
name: string;
created_at: string;
};
export default function AdminPage() {
const [token, setToken] = useState("");
const [tokenInput, setTokenInput] = useState("");
const [tokenMessage, setTokenMessage] = useState<string | null>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [tagsError, setTagsError] = useState<string | null>(null);
const [tagsLoading, setTagsLoading] = useState(false);
const [newTag, setNewTag] = useState("");
const [albums, setAlbums] = useState<Album[]>([]);
const [albumsError, setAlbumsError] = useState<string | null>(null);
const [albumsLoading, setAlbumsLoading] = useState(false);
const [newAlbum, setNewAlbum] = useState("");
useEffect(() => {
if (typeof window === "undefined") return;
const stored = sessionStorage.getItem(ADMIN_TOKEN_KEY) ?? "";
setToken(stored);
setTokenInput(stored);
}, []);
const adminHeaders = useMemo(() => {
if (!token) return null;
return { "X-Porthole-Admin-Token": token };
}, [token]);
async function loadTags() {
if (!adminHeaders) {
setTagsError("Set admin token first.");
return;
}
setTagsLoading(true);
setTagsError(null);
try {
const res = await fetch("/api/tags", {
headers: adminHeaders,
cache: "no-store",
});
if (!res.ok) throw new Error(`tags_fetch_failed:${res.status}`);
const json = (await res.json()) as Tag[];
setTags(json);
} catch (err) {
setTagsError(err instanceof Error ? err.message : String(err));
} finally {
setTagsLoading(false);
}
}
async function loadAlbums() {
if (!adminHeaders) {
setAlbumsError("Set admin token first.");
return;
}
setAlbumsLoading(true);
setAlbumsError(null);
try {
const res = await fetch("/api/albums", {
headers: adminHeaders,
cache: "no-store",
});
if (!res.ok) throw new Error(`albums_fetch_failed:${res.status}`);
const json = (await res.json()) as Album[];
setAlbums(json);
} catch (err) {
setAlbumsError(err instanceof Error ? err.message : String(err));
} finally {
setAlbumsLoading(false);
}
}
async function handleSaveToken(event: React.FormEvent) {
event.preventDefault();
if (typeof window === "undefined") return;
const trimmed = tokenInput.trim();
if (trimmed) {
sessionStorage.setItem(ADMIN_TOKEN_KEY, trimmed);
setToken(trimmed);
setTokenMessage("Token saved for this session.");
} else {
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
setToken("");
setTokenMessage("Token cleared.");
}
}
async function handleCreateTag(event: React.FormEvent) {
event.preventDefault();
if (!adminHeaders) {
setTagsError("Set admin token first.");
return;
}
if (!newTag.trim()) {
setTagsError("Tag name is required.");
return;
}
try {
setTagsError(null);
const res = await fetch("/api/tags", {
method: "POST",
headers: { ...adminHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ name: newTag.trim() }),
});
if (!res.ok) throw new Error(`tag_create_failed:${res.status}`);
setNewTag("");
await loadTags();
} catch (err) {
setTagsError(err instanceof Error ? err.message : String(err));
}
}
async function handleCreateAlbum(event: React.FormEvent) {
event.preventDefault();
if (!adminHeaders) {
setAlbumsError("Set admin token first.");
return;
}
if (!newAlbum.trim()) {
setAlbumsError("Album name is required.");
return;
}
try {
setAlbumsError(null);
const res = await fetch("/api/albums", {
method: "POST",
headers: { ...adminHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ name: newAlbum.trim() }),
});
if (!res.ok) throw new Error(`album_create_failed:${res.status}`);
setNewAlbum("");
await loadAlbums();
} catch (err) {
setAlbumsError(err instanceof Error ? err.message : String(err));
}
}
return (
<main style={{ padding: 16, display: "grid", gap: 20, maxWidth: 720 }}>
<header>
<h1 style={{ marginTop: 0 }}>Admin</h1>
<p style={{ color: "#555" }}>
Manage tags and albums. Admin token is stored in sessionStorage.
</p>
</header>
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>Admin Token</h2>
<form onSubmit={handleSaveToken} style={{ display: "grid", gap: 8 }}>
<input
type="password"
placeholder="X-Porthole-Admin-Token"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
style={{ padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
/>
<div style={{ display: "flex", gap: 8 }}>
<button type="submit">Save token</button>
<button
type="button"
onClick={() => {
setTokenInput("");
setToken("");
if (typeof window !== "undefined") {
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
}
setTokenMessage("Token cleared.");
}}
>
Clear
</button>
</div>
{tokenMessage ? (
<div style={{ fontSize: 12, color: "#444" }}>{tokenMessage}</div>
) : null}
</form>
</section>
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>Tags</h2>
<form onSubmit={handleCreateTag} style={{ display: "flex", gap: 8 }}>
<input
type="text"
placeholder="New tag name"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
/>
<button type="submit">Create</button>
<button type="button" onClick={loadTags} disabled={tagsLoading}>
{tagsLoading ? "Loading..." : "Refresh"}
</button>
</form>
{tagsError ? (
<div style={{ color: "#b00", marginTop: 8 }}>{tagsError}</div>
) : null}
<ul style={{ marginTop: 12, paddingLeft: 16 }}>
{tags.length === 0 ? (
<li style={{ color: "#666" }}>No tags yet.</li>
) : (
tags.map((tag) => <li key={tag.id}>{tag.name}</li>)
)}
</ul>
</section>
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>Albums</h2>
<form onSubmit={handleCreateAlbum} style={{ display: "flex", gap: 8 }}>
<input
type="text"
placeholder="New album name"
value={newAlbum}
onChange={(e) => setNewAlbum(e.target.value)}
style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
/>
<button type="submit">Create</button>
<button type="button" onClick={loadAlbums} disabled={albumsLoading}>
{albumsLoading ? "Loading..." : "Refresh"}
</button>
</form>
{albumsError ? (
<div style={{ color: "#b00", marginTop: 8 }}>{albumsError}</div>
) : null}
<ul style={{ marginTop: 12, paddingLeft: 16 }}>
{albums.length === 0 ? (
<li style={{ color: "#666" }}>No albums yet.</li>
) : (
albums.map((album) => <li key={album.id}>{album.name}</li>)
)}
</ul>
</section>
<main style={{ padding: 16 }}>
<h1 style={{ marginTop: 0 }}>Admin</h1>
<p>Upload + scan tools will live here.</p>
</main>
);
}
@@ -1,57 +0,0 @@
import { z } from "zod";
import {
getAdminOk,
handleAddAlbumAsset,
handleRemoveAlbumAsset,
} from "../../handlers";
export const runtime = "nodejs";
const paramsSchema = z.object({
id: z.string().uuid(),
});
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const bodyJson = await request.json().catch(() => ({}));
const res = await handleAddAlbumAsset({
adminOk: getAdminOk(request.headers),
params: paramsParsed.data,
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
export async function DELETE(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const bodyJson = await request.json().catch(() => ({}));
const res = await handleRemoveAlbumAsset({
adminOk: getAdminOk(request.headers),
params: paramsParsed.data,
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
-184
View File
@@ -1,184 +0,0 @@
import { getAdminToken, isAdminRequest } from "@tline/config";
import { getDb } from "@tline/db";
import { z } from "zod";
const ADMIN_HEADER = "X-Porthole-Admin-Token";
const createAlbumBodySchema = z
.object({
name: z.string().min(1),
})
.strict();
const albumParamsSchema = z.object({
id: z.string().uuid(),
});
const albumAssetBodySchema = z
.object({
assetId: z.string().uuid(),
ord: z.coerce.number().int().optional(),
})
.strict();
type DbLike = ReturnType<typeof getDb>;
export function getAdminOk(headers: Headers) {
const headerToken = headers.get(ADMIN_HEADER);
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
}
export async function handleListAlbums(input: {
adminOk: boolean;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const db = (input.db ?? getDb()) as DbLike;
const rows = await db<
{
id: string;
name: string;
created_at: string;
}[]
>`
select id, name, created_at
from albums
order by created_at desc
`;
return { status: 200, body: rows };
}
export async function handleCreateAlbum(input: {
adminOk: boolean;
body: unknown;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const bodyParsed = createAlbumBodySchema.safeParse(input.body ?? {});
if (!bodyParsed.success) {
return {
status: 400,
body: { error: "invalid_body", issues: bodyParsed.error.issues },
};
}
const body = bodyParsed.data;
const db = (input.db ?? getDb()) as DbLike;
const rows = await db<
{
id: string;
name: string;
created_at: string;
}[]
>`
insert into albums (name)
values (${body.name})
returning id, name, created_at
`;
const created = rows[0];
if (!created) {
return { status: 500, body: { error: "insert_failed" } };
}
const payload = JSON.stringify({ name: created.name });
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'create', 'album', ${created.id}, ${payload}::jsonb)
`;
return { status: 200, body: created };
}
export async function handleAddAlbumAsset(input: {
adminOk: boolean;
params: { id: string };
body: unknown;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const paramsParsed = albumParamsSchema.safeParse(input.params);
if (!paramsParsed.success) {
return {
status: 400,
body: { error: "invalid_params", issues: paramsParsed.error.issues },
};
}
const body = albumAssetBodySchema.parse(input.body ?? {});
const db = (input.db ?? getDb()) as DbLike;
const rows = await db<
{
album_id: string;
asset_id: string;
ord: number | null;
}[]
>`
insert into album_assets (album_id, asset_id, ord)
values (${paramsParsed.data.id}, ${body.assetId}, ${body.ord ?? null})
on conflict (album_id, asset_id)
do update set ord = excluded.ord
returning album_id, asset_id, ord
`;
const created = rows[0];
if (!created) {
return { status: 500, body: { error: "insert_failed" } };
}
const payload = JSON.stringify({
asset_id: created.asset_id,
ord: created.ord,
});
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'add_asset', 'album', ${created.album_id}, ${payload}::jsonb)
`;
return { status: 200, body: created };
}
export async function handleRemoveAlbumAsset(input: {
adminOk: boolean;
params: { id: string };
body: unknown;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const paramsParsed = albumParamsSchema.safeParse(input.params);
if (!paramsParsed.success) {
return {
status: 400,
body: { error: "invalid_params", issues: paramsParsed.error.issues },
};
}
const body = albumAssetBodySchema.parse(input.body ?? {});
const db = (input.db ?? getDb()) as DbLike;
await db`
delete from album_assets
where album_id = ${paramsParsed.data.id}
and asset_id = ${body.assetId}
`;
const payload = JSON.stringify({ asset_id: body.assetId });
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'remove_asset', 'album', ${paramsParsed.data.id}, ${payload}::jsonb)
`;
return { status: 200, body: { ok: true } };
}
-17
View File
@@ -1,17 +0,0 @@
import { getAdminOk, handleCreateAlbum, handleListAlbums } from "./handlers";
export const runtime = "nodejs";
export async function GET(request: Request): Promise<Response> {
const res = await handleListAlbums({ adminOk: getAdminOk(request.headers) });
return Response.json(res.body, { status: res.status });
}
export async function POST(request: Request): Promise<Response> {
const bodyJson = await request.json().catch(() => ({}));
const res = await handleCreateAlbum({
adminOk: getAdminOk(request.headers),
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
@@ -1,56 +0,0 @@
import { z } from "zod";
import { getDb } from "@tline/db";
const paramsSchema = z.object({ id: z.string().uuid() });
type DbLike = ReturnType<typeof getDb>;
export async function handleGetDupes(input: {
params: { id: string };
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
const paramsParsed = paramsSchema.safeParse(input.params);
if (!paramsParsed.success) {
return {
status: 400,
body: { error: "invalid_params", issues: paramsParsed.error.issues },
};
}
const db = (input.db ?? getDb()) as DbLike;
const hashRows = await db<
{
bucket: string;
sha256: string;
}[]
>`
select bucket, sha256
from asset_hashes
where asset_id = ${paramsParsed.data.id}
limit 1
`;
const hash = hashRows[0];
if (!hash) {
return { status: 200, body: { items: [] } };
}
const dupes = await db<
{
id: string;
media_type: "image" | "video";
status: "new" | "processing" | "ready" | "failed";
}[]
>`
select a.id, a.media_type, a.status
from assets a
join asset_hashes h on h.asset_id = a.id
where h.bucket = ${hash.bucket}
and h.sha256 = ${hash.sha256}
and a.id <> ${paramsParsed.data.id}
order by a.id asc
`;
return { status: 200, body: { items: dupes } };
}
@@ -1,12 +0,0 @@
import { handleGetDupes } from "./handlers";
export const runtime = "nodejs";
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const result = await handleGetDupes({ params: rawParams });
return Response.json(result.body, { status: result.status });
}
@@ -1,133 +0,0 @@
import { getAdminToken, isAdminRequest } from "@tline/config";
import { getDb } from "@tline/db";
import { z } from "zod";
const ADMIN_HEADER = "X-Porthole-Admin-Token";
const paramsSchema = z.object({
id: z.string().uuid(),
});
const bodySchema = z
.object({
captureTsUtcOverride: z.string().datetime().nullable().optional(),
captureOffsetMinutesOverride: z.number().int().nullable().optional(),
})
.strict();
type DbLike = ReturnType<typeof getDb>;
export function getAdminOk(headers: Headers) {
const headerToken = headers.get(ADMIN_HEADER);
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
}
export async function handleSetCaptureOverride(input: {
adminOk: boolean;
params: { id: string };
body: unknown;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const paramsParsed = paramsSchema.safeParse(input.params);
if (!paramsParsed.success) {
return {
status: 400,
body: { error: "invalid_params", issues: paramsParsed.error.issues },
};
}
const bodyParsed = bodySchema.safeParse(input.body ?? {});
if (!bodyParsed.success) {
return {
status: 400,
body: { error: "invalid_body", issues: bodyParsed.error.issues },
};
}
const data = bodyParsed.data;
const hasCaptureTs = "captureTsUtcOverride" in data;
const hasCaptureOffset = "captureOffsetMinutesOverride" in data;
if (!hasCaptureTs && !hasCaptureOffset) {
return { status: 400, body: { error: "invalid_body" } };
}
const db = (input.db ?? getDb()) as DbLike;
const captureTs = hasCaptureTs
? data.captureTsUtcOverride
? new Date(data.captureTsUtcOverride)
: null
: null;
const captureOffset = hasCaptureOffset
? data.captureOffsetMinutesOverride ?? null
: null;
const rows = await db<
{
asset_id: string;
capture_ts_utc_override: string | null;
capture_offset_minutes_override: number | null;
created_at: string;
}[]
>`
insert into asset_overrides (
asset_id,
capture_ts_utc_override,
capture_offset_minutes_override
)
values (
${paramsParsed.data.id},
${captureTs},
${captureOffset}
)
on conflict (asset_id)
do update set
capture_ts_utc_override = case
when ${hasCaptureTs} then excluded.capture_ts_utc_override
else asset_overrides.capture_ts_utc_override
end,
capture_offset_minutes_override = case
when ${hasCaptureOffset} then excluded.capture_offset_minutes_override
else asset_overrides.capture_offset_minutes_override
end
returning asset_id, capture_ts_utc_override, capture_offset_minutes_override, created_at
`;
const created = rows[0];
if (!created) {
return { status: 500, body: { error: "insert_failed" } };
}
const assetRows = await db<
{
capture_ts_utc: string | null;
}[]
>`
select capture_ts_utc
from assets
where id = ${created.asset_id}
limit 1
`;
const baseCaptureTs = assetRows[0]?.capture_ts_utc ?? null;
const payload = JSON.stringify({
capture_ts_utc_override: created.capture_ts_utc_override,
capture_offset_minutes_override: created.capture_offset_minutes_override,
});
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'override_capture_ts', 'asset', ${created.asset_id}, ${payload}::jsonb)
`;
return {
status: 200,
body: {
...created,
base_capture_ts_utc: baseCaptureTs,
},
};
}
@@ -1,31 +0,0 @@
import { z } from "zod";
import { getAdminOk, handleSetCaptureOverride } from "./handlers";
export const runtime = "nodejs";
const paramsSchema = z.object({
id: z.string().uuid(),
});
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const bodyJson = await request.json().catch(() => ({}));
const res = await handleSetCaptureOverride({
adminOk: getAdminOk(request.headers),
params: paramsParsed.data,
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
@@ -1,78 +0,0 @@
import { getAdminToken, isAdminRequest } from "@tline/config";
import { getDb } from "@tline/db";
import { z } from "zod";
export const runtime = "nodejs";
const ADMIN_HEADER = "X-Porthole-Admin-Token";
const paramsSchema = z.object({
id: z.string().uuid(),
});
const bodySchema = z
.object({
tagId: z.string().uuid(),
})
.strict();
function getAdminOk(headers: Headers) {
const headerToken = headers.get(ADMIN_HEADER);
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
}
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
if (!getAdminOk(request.headers)) {
return Response.json({ error: "admin_required" }, { status: 401 });
}
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const bodyJson = await request.json().catch(() => ({}));
const bodyParsed = bodySchema.safeParse(bodyJson);
if (!bodyParsed.success) {
return Response.json(
{ error: "invalid_body", issues: bodyParsed.error.issues },
{ status: 400 },
);
}
const db = getDb();
const rows = await db<
{
asset_id: string;
tag_id: string;
}[]
>`
insert into asset_tags (asset_id, tag_id)
values (${paramsParsed.data.id}, ${bodyParsed.data.tagId})
on conflict (asset_id, tag_id)
do nothing
returning asset_id, tag_id
`;
const created =
rows[0] ??
({ asset_id: paramsParsed.data.id, tag_id: bodyParsed.data.tagId } as const);
const payload = JSON.stringify({
asset_id: created.asset_id,
tag_id: created.tag_id,
});
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'add_tag', 'asset', ${created.asset_id}, ${payload}::jsonb)
`;
return Response.json(created, { status: 200 });
}
+17 -126
View File
@@ -2,7 +2,6 @@ import { z } from "zod";
import { getDb } from "@tline/db";
import { presignGetObjectUrl } from "@tline/minio";
import { pickLegacyKeyForRequest, pickVariantKey } from "./variant";
export const runtime = "nodejs";
@@ -10,16 +9,7 @@ const paramsSchema = z.object({
id: z.string().uuid()
});
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 },
};
const variantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]);
export async function GET(
request: Request,
@@ -36,71 +26,14 @@ export async function GET(
const params = paramsParsed.data;
const url = new URL(request.url);
const kindParam = url.searchParams.get("kind");
const sizeParam = url.searchParams.get("size");
const legacyVariantParam = url.searchParams.get("variant");
const endpointParam = url.searchParams.get("endpoint");
let requestedKind: z.infer<typeof kindSchema> = "original";
let requestedSize: number | null = null;
let legacyVariant: z.infer<typeof legacyVariantSchema> | null = null;
let endpointOverride: "lan" | "tailnet" | undefined;
if (kindParam) {
const kindParsed = kindSchema.safeParse(kindParam);
if (!kindParsed.success) {
return Response.json(
{ error: "invalid_query", issues: kindParsed.error.issues },
{ status: 400 },
);
}
requestedKind = kindParsed.data;
if (requestedKind !== "original") {
if (requestedKind === "video_mp4" && !sizeParam) {
requestedSize = videoMp4DefaultSize;
} else {
const sizeParsed = sizeSchema.safeParse(sizeParam);
if (!sizeParsed.success) {
return Response.json(
{ error: "invalid_query", issues: sizeParsed.error.issues },
{ status: 400 },
);
}
requestedSize = sizeParsed.data;
}
}
} else if (legacyVariantParam) {
const legacyParsed = legacyVariantSchema.safeParse(legacyVariantParam);
if (!legacyParsed.success) {
return Response.json(
{ error: "invalid_query", issues: legacyParsed.error.issues },
{ status: 400 },
);
}
legacyVariant = legacyParsed.data;
const mapped = legacyVariantMap[legacyVariant];
requestedKind = mapped.kind;
requestedSize = "size" in mapped ? mapped.size : null;
}
if (endpointParam) {
if (endpointParam !== "lan" && endpointParam !== "tailnet") {
return Response.json(
{
error: "invalid_query",
issues: [
{
code: "custom",
message: "endpoint must be lan or tailnet",
path: ["endpoint"],
},
],
},
{ status: 400 },
);
}
endpointOverride = endpointParam;
const variantParsed = variantSchema.safeParse(url.searchParams.get("variant") ?? "original");
if (!variantParsed.success) {
return Response.json(
{ error: "invalid_query", issues: variantParsed.error.issues },
{ status: 400 },
);
}
const variant = variantParsed.data;
const db = getDb();
const rows = await db<
@@ -119,80 +52,38 @@ 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 =
requestedKind === "original"
variant === "original"
? asset.active_key
: requestedSize !== null
? pickVariantKey(
{ variants },
{ kind: requestedKind, size: requestedSize },
) ?? legacyKey
: null;
: variant === "thumb_small"
? asset.thumb_small_key
: variant === "thumb_med"
? asset.thumb_med_key
: asset.poster_key;
if (!key) {
return Response.json(
{ error: "variant_not_available", kind: requestedKind, size: requestedSize },
{ error: "variant_not_available", variant },
{ status: 404 }
);
}
// Hint the browser; especially helpful for Range playback.
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 responseContentType = variant === "original" ? asset.mime_type : "image/jpeg";
const responseContentDisposition =
(requestedKind === "original" && asset.mime_type.startsWith("video/")) ||
requestedKind === "video_mp4"
? "inline"
: undefined;
variant === "original" && asset.mime_type.startsWith("video/") ? "inline" : undefined;
const signed = await presignGetObjectUrl({
bucket: asset.bucket,
key,
responseContentType,
responseContentDisposition,
endpoint: endpointOverride,
});
return Response.json(signed, {
@@ -1,31 +0,0 @@
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;
}
@@ -1,43 +0,0 @@
import { z } from "zod";
import { getDb } from "@tline/db";
import { shapeVariants } from "./shape";
export const runtime = "nodejs";
const paramsSchema = z.object({
id: z.string().uuid(),
});
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const db = getDb();
const rows = await db<
{
kind: string;
size: number;
key: string;
mime_type: string;
width: number | null;
height: number | null;
}[]
>`
select kind, size, key, mime_type, width, height
from asset_variants
where asset_id = ${paramsParsed.data.id}
`;
return Response.json(shapeVariants(rows));
}
@@ -1,19 +0,0 @@
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,
}));
}
+24 -26
View File
@@ -68,37 +68,35 @@ export async function GET(request: Request): Promise<Response> {
}[]
>`
select
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
id,
bucket,
media_type,
mime_type,
active_key,
capture_ts_utc,
date_confidence,
width,
height,
rotation,
duration_seconds,
thumb_small_key,
thumb_med_key,
poster_key,
status,
error_message
from assets
where true
and 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 capture_ts_utc is not null
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type)
and (${query.status ?? null}::asset_status is null or status = ${query.status ?? null}::asset_status)
and (
${cursorId}::uuid is null
or ${cursorTs}::timestamptz is null
or (coalesce(o.capture_ts_utc_override, a.capture_ts_utc), a.id) > (${cursorTs}::timestamptz, ${cursorId}::uuid)
or (capture_ts_utc, id) > (${cursorTs}::timestamptz, ${cursorId}::uuid)
)
order by coalesce(o.capture_ts_utc_override, a.capture_ts_utc) asc nulls last, a.id asc
order by capture_ts_utc asc nulls last, id asc
limit ${query.limit}
`;
-29
View File
@@ -1,29 +0,0 @@
import { getDb } from "@tline/db";
import { shapeGeoRows } from "./shape";
export const runtime = "nodejs";
export async function GET(): Promise<Response> {
const db = getDb();
const rows = await db<
{
id: string;
gps_lat: number | null;
gps_lon: number | null;
}[]
>`
select
a.id,
a.gps_lat,
a.gps_lon
from assets a
where a.gps_lat is not null
and a.gps_lon is not null
order by a.capture_ts_utc asc nulls last, a.id asc
limit 1000
`;
return Response.json(shapeGeoRows(rows));
}
-19
View File
@@ -1,19 +0,0 @@
type GeoRow = {
id: string;
gps_lat: number | null;
gps_lon: number | null;
};
type GeoPoint = {
id: string;
gps_lat: number | null;
gps_lon: number | null;
};
export function shapeGeoRows(rows: GeoRow[]): GeoPoint[] {
return rows.map((row) => ({
id: row.id,
gps_lat: row.gps_lat,
gps_lon: row.gps_lon,
}));
}
@@ -1,17 +1,66 @@
import { getAdminOk, handleScanMinioImport } from "../handlers";
import { z } from "zod";
import { getDb } from "@tline/db";
import { getMinioBucket } from "@tline/minio";
import { enqueueScanMinioPrefix } from "@tline/queue";
export const runtime = "nodejs";
const paramsSchema = z.object({ id: z.string().uuid() });
const bodySchema = z
.object({
bucket: z.string().min(1).optional(),
prefix: z.string().min(1).default("originals/"),
})
.strict();
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const params = paramsParsed.data;
const bodyJson = await request.json().catch(() => ({}));
const res = await handleScanMinioImport({
adminOk: getAdminOk(request.headers),
params: rawParams,
body: bodyJson,
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,
});
return Response.json(res.body, { status: res.status });
await db`
update imports
set status = 'queued'
where id = ${imp.id}
`;
return Response.json({ ok: true, importId: imp.id, bucket, prefix: body.prefix });
}
+99 -7
View File
@@ -1,16 +1,108 @@
import { getAdminOk, handleUploadImport } from "../handlers";
import { randomUUID } from "crypto";
import { Readable } from "stream";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { z } from "zod";
import { getDb } from "@tline/db";
import { getMinioBucket, getMinioInternalClient } from "@tline/minio";
import { enqueueProcessAsset } from "@tline/queue";
export const runtime = "nodejs";
const paramsSchema = z.object({ id: z.string().uuid() });
const contentTypeMediaMap: Array<{
match: (ct: string) => boolean;
mediaType: "image" | "video";
}> = [
{ match: (ct) => ct.startsWith("image/"), mediaType: "image" },
{ match: (ct) => ct.startsWith("video/"), mediaType: "video" },
];
function inferMediaTypeFromContentType(ct: string): "image" | "video" | null {
const found = contentTypeMediaMap.find((m) => m.match(ct));
return found?.mediaType ?? null;
}
function inferExtFromContentType(ct: string): string {
const parts = ct.split("/");
const ext = parts[1] ?? "bin";
return ext.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "bin";
}
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const res = await handleUploadImport({
adminOk: getAdminOk(request.headers),
params: rawParams,
request,
});
return Response.json(res.body, { status: res.status });
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 });
}
-220
View File
@@ -1,220 +0,0 @@
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 },
};
}
+31 -6
View File
@@ -1,12 +1,37 @@
import { getAdminOk, handleCreateImport } from "./handlers";
import { z } from "zod";
import { getDb } from "@tline/db";
export const runtime = "nodejs";
const bodySchema = z
.object({
type: z.enum(["upload", "minio_scan"]).default("upload"),
})
.strict();
export async function POST(request: Request): Promise<Response> {
const bodyJson = await request.json().catch(() => ({}));
const res = await handleCreateImport({
adminOk: getAdminOk(request.headers),
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
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);
}
-69
View File
@@ -1,69 +0,0 @@
import { z } from "zod";
import { getDb } from "@tline/db";
import { clusterMoments } from "../../lib/moments";
export const runtime = "nodejs";
const querySchema = z
.object({
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
includeFailed: z.coerce.number().int().optional(),
limit: z.coerce.number().int().positive().max(2000).default(1000),
})
.strict();
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const parsed = querySchema.safeParse({
start: url.searchParams.get("start") ?? undefined,
end: url.searchParams.get("end") ?? undefined,
includeFailed: url.searchParams.get("includeFailed") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return Response.json(
{ error: "invalid_query", issues: parsed.error.issues },
{ status: 400 },
);
}
const query = parsed.data;
const start = query.start ? new Date(query.start) : null;
const end = query.end ? new Date(query.end) : null;
const includeFailed = query.includeFailed === 1;
const db = getDb();
const rows = await db<
{
id: string;
capture_ts_utc: string | null;
}[]
>`
select id, capture_ts_utc
from assets
where capture_ts_utc is not null
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
and (${includeFailed}::boolean is true or status <> 'failed')
order by capture_ts_utc asc, id asc
limit ${query.limit}
`;
const clusters = clusterMoments(
rows
.filter((row) => Boolean(row.capture_ts_utc))
.map((row) => ({
id: row.id,
capture_ts_utc: row.capture_ts_utc as string,
})),
);
return Response.json({
start: start ? start.toISOString() : null,
end: end ? end.toISOString() : null,
clusters,
});
}
-87
View File
@@ -1,87 +0,0 @@
import { getAdminToken, isAdminRequest } from "@tline/config";
import { getDb } from "@tline/db";
import { z } from "zod";
const ADMIN_HEADER = "X-Porthole-Admin-Token";
const createTagBodySchema = z
.object({
name: z.string().min(1),
})
.strict();
type DbLike = ReturnType<typeof getDb>;
export function getAdminOk(headers: Headers) {
const headerToken = headers.get(ADMIN_HEADER);
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
}
export async function handleListTags(input: {
adminOk: boolean;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const db = (input.db ?? getDb()) as DbLike;
const rows = await db<
{
id: string;
name: string;
created_at: string;
}[]
>`
select id, name, created_at
from tags
order by created_at desc
`;
return { status: 200, body: rows };
}
export async function handleCreateTag(input: {
adminOk: boolean;
body: unknown;
db?: DbLike;
}): Promise<{ status: number; body: unknown }> {
if (!input.adminOk) {
return { status: 401, body: { error: "admin_required" } };
}
const bodyParsed = createTagBodySchema.safeParse(input.body ?? {});
if (!bodyParsed.success) {
return {
status: 400,
body: { error: "invalid_body", issues: bodyParsed.error.issues },
};
}
const body = bodyParsed.data;
const db = (input.db ?? getDb()) as DbLike;
const rows = await db<
{
id: string;
name: string;
created_at: string;
}[]
>`
insert into tags (name)
values (${body.name})
returning id, name, created_at
`;
const created = rows[0];
if (!created) {
return { status: 500, body: { error: "insert_failed" } };
}
const payload = JSON.stringify({ name: created.name });
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'create', 'tag', ${created.id}, ${payload}::jsonb)
`;
return { status: 200, body: created };
}
-17
View File
@@ -1,17 +0,0 @@
import { getAdminOk, handleCreateTag, handleListTags } from "./handlers";
export const runtime = "nodejs";
export async function GET(request: Request): Promise<Response> {
const res = await handleListTags({ adminOk: getAdminOk(request.headers) });
return Response.json(res.body, { status: res.status });
}
export async function POST(request: Request): Promise<Response> {
const bodyJson = await request.json().catch(() => ({}));
const res = await handleCreateTag({
adminOk: getAdminOk(request.headers),
body: bodyJson,
});
return Response.json(res.body, { status: res.status });
}
+17 -25
View File
@@ -18,7 +18,7 @@ const querySchema = z
type Granularity = z.infer<typeof querySchema>["granularity"];
function sqlGroupExpr(granularity: Granularity, alias: string) {
const col = `${alias}.effective_capture_ts_utc`;
const col = `${alias}.capture_ts_utc`;
if (granularity === "year") return `date_trunc('year', ${col})`;
if (granularity === "month") return `date_trunc('month', ${col})`;
return `date_trunc('day', ${col})`;
@@ -71,31 +71,23 @@ export async function GET(request: Request): Promise<Response> {
>`
with filtered as (
select
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)
id,
bucket,
media_type,
status,
capture_ts_utc,
active_key,
thumb_small_key,
thumb_med_key,
poster_key
from assets
where capture_ts_utc is not null
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type)
and (
${query.includeFailed}::boolean = true
or a.status <> 'failed'
or status <> 'failed'
)
),
grouped as (
@@ -128,7 +120,7 @@ export async function GET(request: Request): Promise<Response> {
where f.bucket = g.bucket
and ${db.unsafe(groupExprF)} = g.group_ts
and f.status = 'ready'
order by f.effective_capture_ts_utc asc
order by f.capture_ts_utc asc
limit 1
) s on true
order by g.group_ts desc
+8 -423
View File
@@ -2,8 +2,6 @@
import { useEffect, useMemo, useState } from "react";
import { pickVideoPlaybackVariant } from "../lib/playback";
type Asset = {
id: string;
media_type: "image" | "video";
@@ -25,17 +23,7 @@ type SignedUrlResponse = {
expiresSeconds: number;
};
type OverrideResponse = {
capture_ts_utc_override: string | null;
base_capture_ts_utc: string | null;
};
type PreviewUrlState = Record<string, string | undefined>;
type VideoPlaybackVariant = { kind: "original" } | { kind: "video_mp4"; size: number };
type VariantsResponse = Array<{ kind: string; size: number; key: string }>;
type Tag = { id: string; name: string };
type Album = { id: string; name: string };
type DupesResponse = { items: Array<{ id: string }> };
function startOfDayUtc(iso: string) {
const d = new Date(iso);
@@ -57,7 +45,7 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
const [viewer, setViewer] = useState<{
asset: Asset;
url: string;
variant: "original" | "thumb_med" | "poster" | "video_mp4";
variant: "original" | "thumb_med" | "poster";
} | null>(null);
const [viewerError, setViewerError] = useState<string | null>(null);
@@ -65,18 +53,6 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
posterUrl: string | null;
} | null>(null);
const [retryKey, setRetryKey] = useState(0);
const [tags, setTags] = useState<Tag[]>([]);
const [albums, setAlbums] = useState<Album[]>([]);
const [tagId, setTagId] = useState("");
const [albumId, setAlbumId] = useState("");
const [adminError, setAdminError] = useState<string | null>(null);
const [adminBusy, setAdminBusy] = useState(false);
const [overrideInput, setOverrideInput] = useState("");
const [overrideError, setOverrideError] = useState<string | null>(null);
const [overrideBusy, setOverrideBusy] = useState(false);
const [baseCaptureTs, setBaseCaptureTs] = useState<string | null>(null);
const [dupes, setDupes] = useState<Array<{ id: string }> | null>(null);
const [dupesError, setDupesError] = useState<string | null>(null);
const range = useMemo(() => {
if (!props.selectedDayIso) return null;
@@ -127,65 +103,16 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
async function loadSignedUrl(
assetId: string,
variant:
| "original"
| "thumb_small"
| "thumb_med"
| "poster"
| "video_mp4_720",
sizeOverride?: number,
variant: "original" | "thumb_small" | "thumb_med" | "poster",
) {
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" });
const res = await fetch(`/api/assets/${assetId}/url?variant=${variant}`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`presign_failed:${res.status}`);
const json = (await res.json()) as SignedUrlResponse;
return json.url;
}
async function 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"}`);
@@ -196,207 +123,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
setViewerError(null);
setVideoFallback(null);
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);
}
const variant: "original" | "thumb_med" | "poster" = "original";
const url = await loadSignedUrl(asset.id, variant);
setViewer({ asset, url, variant });
}
return (
@@ -648,150 +377,6 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
<div style={{ color: "#666", fontSize: 12 }}>
{viewer.asset.id}
</div>
<div
style={{
borderTop: "1px solid #eee",
paddingTop: 12,
display: "grid",
gap: 6,
}}
>
<strong style={{ fontSize: 13 }}>Duplicates</strong>
{dupesError ? (
<div style={{ color: "#b00", fontSize: 12 }}>
{dupesError}
</div>
) : null}
{dupes === null ? (
<div style={{ color: "#666", fontSize: 12 }}>
Loading...
</div>
) : dupes.length === 0 ? (
<div style={{ color: "#666", fontSize: 12 }}>
No duplicates.
</div>
) : (
<div
style={{
display: "grid",
gap: 4,
fontSize: 12,
color: "#444",
}}
>
<div>{dupes.length} duplicate(s)</div>
{dupes.map((dupe) => (
<div key={dupe.id}>{dupe.id.slice(0, 8)}</div>
))}
</div>
)}
</div>
<div
style={{
borderTop: "1px solid #eee",
paddingTop: 12,
display: "grid",
gap: 8,
}}
>
<strong style={{ fontSize: 13 }}>Capture time override</strong>
<div style={{ fontSize: 12, color: "#555" }}>
Effective: {viewer.asset.capture_ts_utc ?? "(unset)"}
</div>
<div style={{ fontSize: 12, color: "#555" }}>
Base: {baseCaptureTs ?? "(unknown)"}
</div>
<div style={{ display: "grid", gap: 6 }}>
<label style={{ fontSize: 12, color: "#555" }}>
ISO timestamp
</label>
<input
type="text"
placeholder="2026-02-01T12:34:56.000Z"
value={overrideInput}
onChange={(e) => setOverrideInput(e.target.value)}
style={{ padding: 6, borderRadius: 6, border: "1px solid #ccc" }}
disabled={overrideBusy}
/>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={handleOverrideCaptureTs}>
Save override
</button>
<button type="button" onClick={handleClearOverride}>
Clear override
</button>
</div>
{overrideError ? (
<div style={{ fontSize: 12, color: "#b00" }}>
{overrideError}
</div>
) : null}
</div>
</div>
<div
style={{
borderTop: "1px solid #eee",
paddingTop: 12,
display: "grid",
gap: 8,
}}
>
<strong style={{ fontSize: 13 }}>Tags & Albums</strong>
{adminError ? (
<div style={{ color: "#b00", fontSize: 12 }}>
{adminError}
</div>
) : null}
<div style={{ display: "grid", gap: 6 }}>
<label style={{ fontSize: 12, color: "#555" }}>
Assign tag
</label>
<div style={{ display: "flex", gap: 8 }}>
<select
value={tagId}
onChange={(e) => setTagId(e.target.value)}
style={{ flex: 1, padding: 6 }}
disabled={adminBusy}
>
<option value="">Select tag</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</select>
<button type="button" onClick={handleAssignTag}>
Assign
</button>
</div>
</div>
<div style={{ display: "grid", gap: 6 }}>
<label style={{ fontSize: 12, color: "#555" }}>
Add to album
</label>
<div style={{ display: "flex", gap: 8 }}>
<select
value={albumId}
onChange={(e) => setAlbumId(e.target.value)}
style={{ flex: 1, padding: 6 }}
disabled={adminBusy}
>
<option value="">Select album</option>
{albums.map((album) => (
<option key={album.id} value={album.id}>
{album.name}
</option>
))}
</select>
<button type="button" onClick={handleAddToAlbum}>
Add
</button>
</div>
</div>
</div>
</>
) : (
<div style={{ color: "#b00" }}>
-62
View File
@@ -27,17 +27,6 @@ type ApiTreeResponse = {
nodes: ApiTreeRow[];
};
type MomentCluster = {
day: string;
count: number;
};
type MomentsResponse = {
start: string | null;
end: string | null;
clusters: MomentCluster[];
};
type Orientation = "vertical" | "horizontal";
type ExpandedState = Record<string, boolean>;
@@ -158,9 +147,6 @@ export function TimelineTree(props: {
const [expanded, setExpanded] = useState<ExpandedState>({});
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [showMoments, setShowMoments] = useState(false);
const [moments, setMoments] = useState<MomentsResponse | null>(null);
const [momentsError, setMomentsError] = useState<string | null>(null);
// simple pan/zoom via viewBox
const svgRef = useRef<SVGSVGElement | null>(null);
@@ -196,38 +182,6 @@ 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),
@@ -361,18 +315,12 @@ export function TimelineTree(props: {
>
Reset view
</button>
<button type="button" onClick={() => setShowMoments((v) => !v)}>
{showMoments ? "Hide moments" : "Show moments"}
</button>
{rows ? (
<span style={{ color: "#666" }}>{rows.length} day nodes</span>
) : null}
</div>
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
{momentsError ? (
<div style={{ color: "#b00" }}>Moments error: {momentsError}</div>
) : null}
{!rows && !error ? (
<div
style={{
@@ -442,15 +390,6 @@ export function TimelineTree(props: {
const isDay = node.id.startsWith("d:");
const clickCursor = hasChildren || isDay ? "pointer" : "default";
const dayKey = node.label;
const dayMoments = showMoments
? moments?.clusters.filter((c) => c.day === dayKey) ?? []
: [];
const momentsCount = dayMoments.reduce(
(sum, c) => sum + c.count,
0,
);
return (
<g
key={node.id}
@@ -465,7 +404,6 @@ export function TimelineTree(props: {
<text x={20} y={5} fontSize={14} fill="#111">
{node.label} ({node.countReady}/{node.countTotal})
{hasChildren ? (isExpanded ? " ▼" : " ▶") : ""}
{showMoments && isDay ? ` · ${momentsCount} moment assets` : ""}
</text>
</g>
);
-1
View File
@@ -1,6 +1,5 @@
import type { ReactNode } from "react";
import { getAppName } from "@tline/config";
import "leaflet/dist/leaflet.css";
export const metadata = {
title: getAppName()
-84
View File
@@ -1,84 +0,0 @@
export type MomentAsset = {
id: string;
capture_ts_utc: string;
};
export type MomentCluster = {
day: string;
start: string;
end: string;
count: number;
assets: MomentAsset[];
};
const MOMENT_WINDOW_MINUTES = 30;
const MOMENT_WINDOW_MS = MOMENT_WINDOW_MINUTES * 60 * 1000;
function dayKeyFromIso(iso: string) {
const d = new Date(iso);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
export function clusterMoments(input: MomentAsset[]): MomentCluster[] {
const byDay = new Map<string, MomentAsset[]>();
for (const asset of input) {
if (!asset.capture_ts_utc) continue;
const key = dayKeyFromIso(asset.capture_ts_utc);
const list = byDay.get(key);
if (list) list.push(asset);
else byDay.set(key, [asset]);
}
const clusters: MomentCluster[] = [];
for (const [day, assets] of byDay) {
const sorted = [...assets].sort((a, b) =>
a.capture_ts_utc.localeCompare(b.capture_ts_utc),
);
let current: MomentAsset[] = [];
let lastTs: number | null = null;
for (const asset of sorted) {
const ts = new Date(asset.capture_ts_utc).getTime();
if (!Number.isFinite(ts)) continue;
if (lastTs === null || ts - lastTs <= MOMENT_WINDOW_MS) {
current.push(asset);
} else {
const start = current[0]?.capture_ts_utc;
const end = current[current.length - 1]?.capture_ts_utc;
if (start && end) {
clusters.push({
day,
start,
end,
count: current.length,
assets: current,
});
}
current = [asset];
}
lastTs = ts;
}
if (current.length) {
const start = current[0].capture_ts_utc;
const end = current[current.length - 1].capture_ts_utc;
clusters.push({
day,
start,
end,
count: current.length,
assets: current,
});
}
}
return clusters;
}
-22
View File
@@ -1,22 +0,0 @@
type Variant = {
kind: "video_mp4";
size: number;
key: string;
};
type PlaybackInput = {
originalMimeType: string | null | undefined;
variants: Variant[];
};
export function pickVideoPlaybackVariant(
input: PlaybackInput,
): { kind: "video_mp4"; size: number } | null {
const mp4Variant = input.variants.find(
(variant) => variant.kind === "video_mp4" && variant.size === 720,
);
if (mp4Variant) {
return { kind: "video_mp4", size: mp4Variant.size };
}
return null;
}
-95
View File
@@ -1,95 +0,0 @@
"use client";
import L from "leaflet";
import { useEffect, useRef, useState } from "react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
type GeoPoint = {
id: string;
gps_lat: number | null;
gps_lon: number | null;
};
function MapContent({ points, error }: { points: GeoPoint[]; error: string | null }) {
const map = useMap();
const markersRef = useRef<any[]>([]);
useEffect(() => {
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
if (points.length === 0) return;
points.forEach((point) => {
if (point.gps_lat === null || point.gps_lon === null) return;
const marker = L.marker([point.gps_lat, point.gps_lon]);
marker.addTo(map);
markersRef.current.push(marker);
});
if (points.length > 0) {
const group = L.featureGroup(markersRef.current);
map.fitBounds(group.getBounds().pad(0.1));
}
}, [points, map]);
return null;
}
export default function MapPage() {
const [points, setPoints] = useState<GeoPoint[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/geo")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch geo points");
return res.json();
})
.then((data) => {
setPoints(data);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Unknown error");
})
.finally(() => {
setLoading(false);
});
}, []);
return (
<main style={{ padding: 16, display: "grid", gap: 16, height: "calc(100vh - 32px)" }}>
<header>
<h1 style={{ marginTop: 0 }}>Map</h1>
</header>
{loading ? (
<div style={{ textAlign: "center", padding: 40 }}>
Loading map...
</div>
) : error ? (
<div style={{ textAlign: "center", padding: 40, color: "#b00" }}>
Error: {error}
</div>
) : points.length === 0 ? (
<div style={{ textAlign: "center", padding: 40, color: "#666" }}>
No GPS points available
</div>
) : (
<div style={{ flex: 1, minHeight: 0 }}>
<MapContainer
style={{ height: "100%", width: "100%" }}
boundsOptions={{ padding: [50, 50] }}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapContent points={points} error={error} />
</MapContainer>
</div>
)}
</main>
);
}
-3
View File
@@ -16,9 +16,6 @@ export default function HomePage() {
<header>
<h1 style={{ marginTop: 0 }}>{getAppName()}</h1>
<ul>
<li>
<Link href="/map">Map</Link>
</li>
<li>
<Link href="/admin">Admin</Link>
</li>
@@ -1,30 +0,0 @@
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" });
});
@@ -1,161 +0,0 @@
import { test, expect } from "bun:test";
function createMockDb(responses: Array<unknown>) {
const calls: Array<{ sql: string; values: unknown[] }> = [];
const db = async <T>(strings: TemplateStringsArray, ...values: unknown[]) => {
calls.push({ sql: strings.join(""), values });
const next = responses.shift();
return next as T;
};
return { db, calls };
}
test("albums POST rejects when missing admin token", async () => {
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
const res = await handleCreateAlbum({ adminOk: false, body: {} });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("albums GET rejects when missing admin token", async () => {
const { handleListAlbums } = await import("../../app/api/albums/handlers");
const res = await handleListAlbums({ adminOk: false });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("album add asset rejects when missing admin token", async () => {
const { handleAddAlbumAsset } = await import(
"../../app/api/albums/handlers"
);
const res = await handleAddAlbumAsset({
adminOk: false,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { assetId: "00000000-0000-4000-8000-000000000000" },
});
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("album remove asset rejects when missing admin token", async () => {
const { handleRemoveAlbumAsset } = await import(
"../../app/api/albums/handlers"
);
const res = await handleRemoveAlbumAsset({
adminOk: false,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { assetId: "00000000-0000-4000-8000-000000000000" },
});
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("albums GET returns rows", async () => {
const { handleListAlbums } = await import("../../app/api/albums/handlers");
const { db } = createMockDb([
[
{
id: "00000000-0000-4000-8000-000000000010",
name: "Summer",
created_at: "2026-02-01T00:00:00.000Z",
},
],
]);
const res = await handleListAlbums({ adminOk: true, db: db as never });
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
id: "00000000-0000-4000-8000-000000000010",
name: "Summer",
created_at: "2026-02-01T00:00:00.000Z",
},
]);
});
test("albums POST inserts and writes audit log", async () => {
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
const { db, calls } = createMockDb([
[
{
id: "00000000-0000-4000-8000-000000000020",
name: "Trips",
created_at: "2026-02-01T00:00:00.000Z",
},
],
[],
]);
const res = await handleCreateAlbum({
adminOk: true,
body: { name: "Trips" },
db: db as never,
});
expect(res.status).toBe(200);
expect(res.body).toEqual({
id: "00000000-0000-4000-8000-000000000020",
name: "Trips",
created_at: "2026-02-01T00:00:00.000Z",
});
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
true,
);
});
test("albums POST rejects invalid body", async () => {
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
const res = await handleCreateAlbum({ adminOk: true, body: { name: "" } });
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true);
});
test("album add asset inserts and writes audit log", async () => {
const { handleAddAlbumAsset } = await import(
"../../app/api/albums/handlers"
);
const { db, calls } = createMockDb([
[
{
album_id: "00000000-0000-4000-8000-000000000030",
asset_id: "00000000-0000-4000-8000-000000000031",
ord: 2,
},
],
[],
]);
const res = await handleAddAlbumAsset({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000030" },
body: { assetId: "00000000-0000-4000-8000-000000000031", ord: 2 },
db: db as never,
});
expect(res.status).toBe(200);
expect(res.body).toEqual({
album_id: "00000000-0000-4000-8000-000000000030",
asset_id: "00000000-0000-4000-8000-000000000031",
ord: 2,
});
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
true,
);
});
test("album remove asset deletes and writes audit log", async () => {
const { handleRemoveAlbumAsset } = await import(
"../../app/api/albums/handlers"
);
const { db, calls } = createMockDb([[], [], []]);
const res = await handleRemoveAlbumAsset({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000040" },
body: { assetId: "00000000-0000-4000-8000-000000000041" },
db: db as never,
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(calls.some((call) => call.sql.includes("delete from album_assets"))).toBe(
true,
);
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
true,
);
});
@@ -1,180 +0,0 @@
import { test, expect } from "bun:test";
import type { getDb } from "@tline/db";
type DbRow = {
asset_id: string;
capture_ts_utc_override: string | null;
capture_offset_minutes_override: number | null;
created_at: string;
};
const createDbStub = (initial: DbRow) => {
let current = { ...initial };
const db = async <T>(
strings: TemplateStringsArray,
...values: unknown[]
): Promise<T> => {
const query = strings.join("");
if (query.includes("insert into asset_overrides")) {
const [assetId, captureTs, captureOffset, tsProvided, offsetProvided] =
values;
const hasFlags =
typeof tsProvided === "boolean" && typeof offsetProvided === "boolean";
const updateTs = hasFlags ? (tsProvided as boolean) : true;
const updateOffset = hasFlags ? (offsetProvided as boolean) : true;
if (updateTs) {
if (captureTs instanceof Date) {
current.capture_ts_utc_override = captureTs.toISOString();
} else if (captureTs === null) {
current.capture_ts_utc_override = null;
} else {
current.capture_ts_utc_override = String(captureTs ?? "");
}
}
if (updateOffset) {
current.capture_offset_minutes_override =
captureOffset as number | null;
}
return [
{
asset_id: String(assetId),
capture_ts_utc_override: current.capture_ts_utc_override,
capture_offset_minutes_override: current.capture_offset_minutes_override,
created_at: current.created_at,
},
] as T;
}
if (query.includes("insert into audit_log")) {
return [] as T;
}
if (query.includes("select capture_ts_utc") && query.includes("from assets")) {
return [{ capture_ts_utc: current.capture_ts_utc_override }] as T;
}
throw new Error(`Unexpected query: ${query}`);
};
return db as unknown as ReturnType<typeof getDb>;
};
test("asset overrides POST rejects when missing admin token", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const res = await handleSetCaptureOverride({
adminOk: false,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { captureTsUtcOverride: "2026-02-01T00:00:00.000Z" },
});
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("asset overrides POST rejects invalid body", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { captureTsUtcOverride: "not-a-date" },
});
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true);
});
test("asset overrides POST rejects unknown fields", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: {
captureTsUtcOverride: "2026-02-01T00:00:00.000Z",
extra: "nope",
},
});
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
});
test("asset overrides POST rejects string offset", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: {
captureOffsetMinutesOverride: "15",
},
});
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
});
test("asset overrides POST rejects empty body", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: {},
});
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
});
test("asset overrides POST preserves omitted fields", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const db = createDbStub({
asset_id: "00000000-0000-4000-8000-000000000000",
capture_ts_utc_override: "2026-01-01T00:00:00.000Z",
capture_offset_minutes_override: 90,
created_at: "2026-02-01T00:00:00.000Z",
});
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { captureTsUtcOverride: "2026-02-01T00:00:00.000Z" },
db,
});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
capture_ts_utc_override: "2026-02-01T00:00:00.000Z",
capture_offset_minutes_override: 90,
});
});
test("asset overrides POST allows explicit null clearing", async () => {
const { handleSetCaptureOverride } = await import(
"../../app/api/assets/[id]/override-capture-ts/handlers"
);
const db = createDbStub({
asset_id: "00000000-0000-4000-8000-000000000000",
capture_ts_utc_override: "2026-01-01T00:00:00.000Z",
capture_offset_minutes_override: 90,
created_at: "2026-02-01T00:00:00.000Z",
});
const res = await handleSetCaptureOverride({
adminOk: true,
params: { id: "00000000-0000-4000-8000-000000000000" },
body: { captureOffsetMinutesOverride: null },
db,
});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
capture_offset_minutes_override: null,
});
});
@@ -1,54 +0,0 @@
import { describe, expect, test } from "bun:test";
import { handleGetDupes } from "../../app/api/assets/[id]/dupes/handlers";
describe("handleGetDupes", () => {
test("returns empty list when hash is missing", async () => {
let call = 0;
const db = async () => {
call += 1;
return [] as unknown[];
};
const result = await handleGetDupes({
params: { id: "00000000-0000-0000-0000-000000000000" },
db,
});
expect(result.status).toBe(200);
expect(result.body).toEqual({ items: [] });
expect(call).toBe(1);
});
test("returns dupes excluding the asset id", async () => {
const calls: unknown[] = [];
const db = async () => {
calls.push(true);
if (calls.length === 1) {
return [{ bucket: "photos", sha256: "hash" }];
}
return [
{
id: "11111111-1111-1111-1111-111111111111",
media_type: "image",
status: "ready",
},
];
};
const result = await handleGetDupes({
params: { id: "00000000-0000-0000-0000-000000000000" },
db,
});
expect(result.status).toBe(200);
expect(result.body).toEqual({
items: [
{
id: "11111111-1111-1111-1111-111111111111",
media_type: "image",
status: "ready",
},
],
});
expect(calls.length).toBe(2);
});
});
-17
View File
@@ -1,17 +0,0 @@
import { test, expect } from "bun:test";
test("shapeGeoRows returns id/lat/lon only", async () => {
const { shapeGeoRows } = await import("../../app/api/geo/shape");
const rows = [
{
id: "a",
gps_lat: 40.1,
gps_lon: -73.9,
capture_ts_utc: "2026-02-01T00:00:00.000Z",
media_type: "image",
},
];
expect(shapeGeoRows(rows)).toEqual([
{ id: "a", gps_lat: 40.1, gps_lon: -73.9 },
]);
});
-47
View File
@@ -1,47 +0,0 @@
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");
});
@@ -1,18 +0,0 @@
import { test, expect } from "bun:test";
import { pickVideoPlaybackVariant } from "../../app/lib/playback";
test("prefer mp4 derived over original", () => {
const picked = pickVideoPlaybackVariant({
originalMimeType: "video/x-matroska",
variants: [{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4" }],
});
expect(picked).toEqual({ kind: "video_mp4", size: 720 });
});
test("returns null when no mp4 variants", () => {
const picked = pickVideoPlaybackVariant({
originalMimeType: "video/x-matroska",
variants: [],
});
expect(picked).toBeNull();
});
-3
View File
@@ -1,3 +0,0 @@
import { expect, test } from "bun:test";
test("bun test runs", () => expect(1 + 1).toBe(2));
@@ -1,83 +0,0 @@
import { test, expect } from "bun:test";
function createMockDb(responses: Array<unknown>) {
const calls: Array<{ sql: string; values: unknown[] }> = [];
const db = async <T>(strings: TemplateStringsArray, ...values: unknown[]) => {
calls.push({ sql: strings.join(""), values });
const next = responses.shift();
return next as T;
};
return { db, calls };
}
test("tags POST rejects when missing admin token", async () => {
const { handleCreateTag } = await import("../../app/api/tags/handlers");
const res = await handleCreateTag({ adminOk: false, body: {} });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("tags GET rejects when missing admin token", async () => {
const { handleListTags } = await import("../../app/api/tags/handlers");
const res = await handleListTags({ adminOk: false });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "admin_required" });
});
test("tags GET returns rows", async () => {
const { handleListTags } = await import("../../app/api/tags/handlers");
const { db } = createMockDb([
[
{
id: "00000000-0000-4000-8000-000000000001",
name: "Pets",
created_at: "2026-02-01T00:00:00.000Z",
},
],
]);
const res = await handleListTags({ adminOk: true, db: db as never });
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
id: "00000000-0000-4000-8000-000000000001",
name: "Pets",
created_at: "2026-02-01T00:00:00.000Z",
},
]);
});
test("tags POST inserts and writes audit log", async () => {
const { handleCreateTag } = await import("../../app/api/tags/handlers");
const { db, calls } = createMockDb([
[
{
id: "00000000-0000-4000-8000-000000000002",
name: "Trips",
created_at: "2026-02-01T00:00:00.000Z",
},
],
[],
]);
const res = await handleCreateTag({
adminOk: true,
body: { name: "Trips" },
db: db as never,
});
expect(res.status).toBe(200);
expect(res.body).toEqual({
id: "00000000-0000-4000-8000-000000000002",
name: "Trips",
created_at: "2026-02-01T00:00:00.000Z",
});
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
true,
);
});
test("tags POST rejects invalid body", async () => {
const { handleCreateTag } = await import("../../app/api/tags/handlers");
const res = await handleCreateTag({ adminOk: true, body: { name: "" } });
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "invalid_body" });
expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true);
});
@@ -1,33 +0,0 @@
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();
});
@@ -1,13 +0,0 @@
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" },
]);
});
File diff suppressed because one or more lines are too long
@@ -1,10 +0,0 @@
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);
});
@@ -1,17 +0,0 @@
import { test, expect } from "bun:test";
import { computeImageVariantPlan, pickSmallestVariantSize } from "../variants";
test("computeImageVariantPlan includes 256 and 768 thumbs", () => {
expect(computeImageVariantPlan()).toEqual([
{ kind: "thumb", size: 256 },
{ kind: "thumb", size: 768 },
]);
});
test("pickSmallestVariantSize returns smallest poster size", () => {
const size = pickSmallestVariantSize([
{ kind: "poster", size: 768 },
{ kind: "poster", size: 256 },
]);
expect(size).toBe(256);
});
-12
View File
@@ -1,12 +0,0 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
export async function computeFileSha256(filePath: string): Promise<string> {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
return await new Promise((resolve, reject) => {
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
+1 -3
View File
@@ -7,8 +7,7 @@ import { closeDb } from "@tline/db";
import {
handleCopyToCanonical,
handleProcessAsset,
handleScanMinioPrefix,
handleTranscodeVideoMp4
handleScanMinioPrefix
} from "./jobs";
console.log(`[${getAppName()}] worker boot`);
@@ -31,7 +30,6 @@ 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}`);
},
+54 -330
View File
@@ -7,12 +7,6 @@ import { Readable } from "stream";
import sharp from "sharp";
import {
computeImageVariantPlan,
computeVideoPosterPlan,
pickSmallestVariantSize,
} from "./variants";
import {
CopyObjectCommand,
GetObjectCommand,
@@ -27,15 +21,10 @@ 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) {
@@ -216,45 +205,6 @@ async function uploadObject(input: {
);
}
async function upsertVariant(input: {
assetId: string;
kind: "thumb" | "poster" | "video_mp4";
size: number;
key: string;
mimeType: string;
width?: number | null;
height?: number | null;
}) {
const db = getDb();
await db`
insert into asset_variants (asset_id, kind, size, key, mime_type, width, height)
values (
${input.assetId},
${input.kind},
${input.size},
${input.key},
${input.mimeType},
${input.width ?? null},
${input.height ?? null}
)
on conflict (asset_id, kind, size)
do update set key = excluded.key,
mime_type = excluded.mime_type,
width = excluded.width,
height = excluded.height
`;
}
async function upsertAssetHash(input: { assetId: string; bucket: string; sha256: string }) {
const db = getDb();
await db`
insert into asset_hashes (asset_id, bucket, sha256)
values (${input.assetId}, ${input.bucket}, ${input.sha256})
on conflict (asset_id)
do update set sha256 = excluded.sha256, bucket = excluded.bucket
`;
}
async function getObjectLastModified(input: { bucket: string; key: string }): Promise<Date | null> {
const s3 = getMinioInternalClient();
const res = await s3.send(new HeadObjectCommand({ Bucket: input.bucket, Key: input.key }));
@@ -282,105 +232,6 @@ function parseExifDate(dateStr: string | undefined): Date | null {
return isNaN(date.getTime()) ? null : date;
}
function parseGpsParts(parts: number[]): number | null {
if (parts.length === 0 || !Number.isFinite(parts[0])) return null;
const [deg, min, sec] = parts;
const sign = deg < 0 ? -1 : 1;
let value = Math.abs(deg);
if (Number.isFinite(min)) value += Math.abs(min) / 60;
if (Number.isFinite(sec)) value += Math.abs(sec) / 3600;
return sign * value;
}
function parseGpsFraction(input: string): number | null {
const trimmed = input.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/);
if (!match) return null;
const numerator = Number(match[1]);
const denominator = Number(match[2]);
if (!Number.isFinite(numerator) || !Number.isFinite(denominator)) return null;
if (denominator === 0) return null;
return numerator / denominator;
}
function parseGpsValue(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return null;
const direct = Number(trimmed);
if (!Number.isNaN(direct)) return direct;
const fraction = parseGpsFraction(trimmed);
if (fraction !== null) return fraction;
const parts = trimmed.match(/-?\d+(?:\.\d+)?/g);
if (!parts) return null;
return parseGpsParts(parts.map((part) => Number(part)).filter(Number.isFinite));
}
if (Array.isArray(value)) {
const parts = value
.map((part) => {
if (typeof part === "number") return part;
if (typeof part === "string") {
const fraction = parseGpsFraction(part);
if (fraction !== null) return fraction;
return Number(part);
}
if (typeof part === "object" && part !== null) {
const candidate = part as Record<string, unknown>;
const numerator = Number(candidate.numerator);
const denominator = Number(candidate.denominator);
if (Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0) {
return numerator / denominator;
}
}
return NaN;
})
.filter(Number.isFinite);
return parseGpsParts(parts);
}
return null;
}
function applyRefSign(value: number, ref: unknown, valueRaw: unknown): number {
const refChar = typeof ref === "string" ? ref.trim().toUpperCase() : "";
const rawChar =
typeof valueRaw === "string"
? (valueRaw.trim().match(/[NSEW]/i)?.[0]?.toUpperCase() ?? "")
: "";
const normalized = refChar || rawChar;
if (normalized === "S" || normalized === "W") return -Math.abs(value);
if (normalized === "N" || normalized === "E") return Math.abs(value);
return value;
}
function parseGpsCoord(
value: unknown,
ref: unknown,
kind: "lat" | "lon",
): number | null {
const parsed = parseGpsValue(value);
if (parsed === null) return null;
const signed = applyRefSign(parsed, ref, value);
if (!Number.isFinite(signed)) return null;
if (kind === "lat") {
return signed >= -90 && signed <= 90 ? signed : null;
}
return signed >= -180 && signed <= 180 ? signed : null;
}
function extractGps(tags: Record<string, unknown>) {
const lat = parseGpsCoord(tags.GPSLatitude, tags.GPSLatitudeRef, "lat");
const lon = parseGpsCoord(tags.GPSLongitude, tags.GPSLongitudeRef, "lon");
if (lat === null || lon === null) return null;
return { lat, lon };
}
function isPlausibleCaptureTs(date: Date) {
const ts = date.getTime();
if (!Number.isFinite(ts)) return false;
@@ -448,13 +299,10 @@ 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 sha256 = await computeFileSha256(inputPath);
await upsertAssetHash({ assetId: asset.id, bucket: asset.bucket, sha256 });
const updates: Record<string, unknown> = {
const updates: Record<string, unknown> = {
capture_ts_utc: null,
date_confidence: null,
width: null,
@@ -464,9 +312,7 @@ export async function handleProcessAsset(raw: unknown) {
thumb_small_key: null,
thumb_med_key: null,
poster_key: null,
raw_tags_json: null,
gps_lat: null,
gps_lon: null
raw_tags_json: null
};
let rawTags: Record<string, unknown> = {};
let captureTs: Date | null = null;
@@ -540,11 +386,6 @@ 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();
@@ -556,45 +397,38 @@ 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 imagePlan = computeImageVariantPlan();
const thumbKeys: Record<number, string> = {};
for (const item of imagePlan) {
const size = item.size;
const thumbPath = join(tempDir, `thumb_${size}.jpg`);
await sharp(inputPath)
.rotate()
.resize(size, size, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(thumbPath);
const 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 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;
const thumb256Key = `thumbs/${asset.id}/image_256.jpg`;
const thumb768Key = `thumbs/${asset.id}/image_768.jpg`;
await uploadObject({
bucket: asset.bucket,
key: thumb256Key,
filePath: thumb256Path,
contentType: "image/jpeg",
});
await uploadObject({
bucket: asset.bucket,
key: thumb768Key,
filePath: thumb768Path,
contentType: "image/jpeg",
});
updates.thumb_small_key = thumb256Key;
updates.thumb_med_key = thumb768Key;
} else if (asset.media_type === "video") {
rawTags = await tryReadExifTags();
maybeSetCaptureDateFromTags(rawTags);
const gps = extractGps(rawTags);
if (gps) {
updates.gps_lat = gps.lat;
updates.gps_lon = gps.lon;
}
const ffprobeOutput = await runCommand("ffprobe", [
"-v",
@@ -631,43 +465,27 @@ export async function handleProcessAsset(raw: unknown) {
rawTags = { ...rawTags, ffprobe: ffprobeData };
const posterPlan = computeVideoPosterPlan();
const posterSmallest = pickSmallestVariantSize(posterPlan);
const posterKeys: Record<number, string> = {};
for (const item of posterPlan) {
const size = item.size;
const posterPath = join(tempDir, `poster_${size}.jpg`);
await runCommand("ffmpeg", [
"-i",
inputPath,
"-vf",
`scale=${size}:${size}:force_original_aspect_ratio=decrease`,
"-vframes",
"1",
"-q:v",
"2",
"-y",
posterPath
]);
const posterKey = `thumbs/${asset.id}/poster_${size}.jpg`;
await uploadObject({
bucket: asset.bucket,
key: posterKey,
filePath: posterPath,
contentType: "image/jpeg",
});
await upsertVariant({
assetId: asset.id,
kind: "poster",
size,
key: posterKey,
mimeType: "image/jpeg",
width: typeof updates.width === "number" ? updates.width : null,
height: typeof updates.height === "number" ? updates.height : null,
});
posterKeys[size] = posterKey;
}
updates.poster_key = posterSmallest ? posterKeys[posterSmallest] ?? null : null;
const posterPath = join(tempDir, "poster_256.jpg");
await runCommand("ffmpeg", [
"-i",
inputPath,
"-vf",
"scale=256:256:force_original_aspect_ratio=decrease",
"-vframes",
"1",
"-q:v",
"2",
"-y",
posterPath
]);
const posterKey = `thumbs/${asset.id}/poster_256.jpg`;
await uploadObject({
bucket: asset.bucket,
key: posterKey,
filePath: posterPath,
contentType: "image/jpeg",
});
updates.poster_key = posterKey;
}
if (asset.media_type === "video" && typeof updates.poster_key !== "string") {
@@ -708,17 +526,11 @@ export async function handleProcessAsset(raw: unknown) {
"thumb_small_key",
"thumb_med_key",
"poster_key",
"raw_tags_json",
"gps_lat",
"gps_lon"
"raw_tags_json"
)}, 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 });
@@ -741,94 +553,6 @@ export async function handleProcessAsset(raw: unknown) {
}
}
export async function handleTranscodeVideoMp4(raw: unknown) {
const payload = transcodeVideoMp4PayloadSchema.parse(raw);
const db = getDb();
const s3 = getMinioInternalClient();
const [asset] = await db<
{
id: string;
bucket: string;
active_key: string;
mime_type: string;
}[]
>`
select id, bucket, active_key, mime_type
from assets
where id = ${payload.assetId}
limit 1
`;
if (!asset) throw new Error(`Asset not found: ${payload.assetId}`);
if (!shouldTranscodeToMp4({ mimeType: asset.mime_type })) {
return { ok: true, assetId: asset.id, skipped: "already_mp4" };
}
const tempDir = await mkdtemp(join(tmpdir(), "tline-transcode-"));
try {
const containerExt = asset.mime_type.split("/")[1] ?? "bin";
const inputPath = join(tempDir, `input.${containerExt}`);
const getRes = await s3.send(
new GetObjectCommand({
Bucket: asset.bucket,
Key: asset.active_key,
}),
);
if (!getRes.Body) throw new Error("Empty response body from S3");
await streamToFile(getRes.Body as Readable, inputPath);
const sha256 = await computeFileSha256(inputPath);
await upsertAssetHash({ assetId: asset.id, bucket: asset.bucket, sha256 });
const outputPath = join(tempDir, "mp4_720p.mp4");
await runCommand("ffmpeg", [
"-i",
inputPath,
"-vf",
"scale=-2:720",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
"-y",
outputPath,
]);
const derivedKey = `derived/video/${asset.id}/mp4_720p.mp4`;
await uploadObject({
bucket: asset.bucket,
key: derivedKey,
filePath: outputPath,
contentType: "video/mp4",
});
await upsertVariant({
assetId: asset.id,
kind: "video_mp4",
size: 720,
key: derivedKey,
mimeType: "video/mp4",
width: null,
height: 720,
});
return { ok: true, assetId: asset.id, key: derivedKey };
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
export async function handleCopyToCanonical(raw: unknown) {
const payload = copyToCanonicalPayloadSchema.parse(raw);
-3
View File
@@ -1,3 +0,0 @@
export function shouldTranscodeToMp4(input: { mimeType: string }) {
return input.mimeType !== "video/mp4";
}
-23
View File
@@ -1,23 +0,0 @@
export type VariantPlanItem = {
kind: "thumb" | "poster";
size: number;
};
export function pickSmallestVariantSize(plan: VariantPlanItem[]): number | null {
if (plan.length === 0) return null;
return plan.reduce((min, item) => (item.size < min ? item.size : min), plan[0].size);
}
export function computeImageVariantPlan(): VariantPlanItem[] {
return [
{ kind: "thumb", size: 256 },
{ kind: "thumb", size: 768 },
];
}
export function computeVideoPosterPlan(): VariantPlanItem[] {
return [
{ kind: "poster", size: 256 },
{ kind: "poster", size: 768 },
];
}
-13
View File
@@ -5,12 +5,9 @@
"": {
"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",
@@ -275,8 +272,6 @@
"@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=="],
@@ -395,12 +390,8 @@
"@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=="],
@@ -527,8 +518,6 @@
"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=="],
@@ -587,8 +576,6 @@
"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=="],
+84
View File
@@ -0,0 +1,84 @@
# Plan: Enable Porthole Web and Worker Applications
## Overview
Currently, the porthole namespace has infrastructure deployed (MinIO, Postgres, Redis) but the main applications are intentionally disabled until container images are built. This plan outlines the steps to build images and enable the web and worker services.
## Current State
- ✅ MinIO, Postgres, Redis running
- ✅ Cleanup CronJob active
- ❌ Web app disabled (no UI)
- ❌ Worker app disabled (no job processing)
- ❌ Migration job disabled (no schema migrations)
## Required Actions
### 1. Build Container Images
Build and push multi-arch images for both web and worker:
```bash
# Build web app (Next.js standalone)
REGISTRY=gitea-gitea-http.taildb3494.ts.net TAG=dev
docker buildx build --platform linux/amd64,linux/arm64 \
-f apps/web/Dockerfile \
-t "$REGISTRY/will/porthole-web:$TAG" \
--push .
# Build worker app (includes ffmpeg + exiftool)
REGISTRY=gitea-gitea-http.taildb3494.ts.net TAG=dev
docker buildx build --platform linux/amd64,linux/arm64 \
-f apps/worker/Dockerfile \
-t "$REGISTRY/will/porthole-worker:$TAG" \
--push .
```
### 2. Update Helm Values
Enable services in `helm/porthole/values-cluster.yaml`:
```yaml
# Enable main applications
web:
enabled: true
worker:
enabled: true
# Enable migrations
jobs:
migrate:
enabled: true
```
### 3. ArgoCD Sync
Changes will sync automatically, or trigger manually:
```bash
argocd app sync porthole
```
## Verification
After sync, verify:
```bash
# Check deployments
kubectl get deployments -n porthole
# Check pods are running
kubectl get pods -n porthole
# Check logs
kubectl logs -f deployment/porthole-porthole-web -n porthole
kubectl logs -f deployment/porthole-porthole-worker -n porthole
```
## Testing
- Access web UI via Tailscale ingress: `app.taildb3494.ts.net`
- Upload a test asset and verify worker processes it
- Check MinIO for stored objects
- Verify database migrations ran successfully
@@ -1,681 +0,0 @@
# All Future Features Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Implement all items in `PLAN.md` under "Future Features (Tracked)" for the `porthole` app.
**Architecture:** Add features incrementally behind small, testable boundaries: shared-secret admin auth for writes, a normalized derived-variant model for media, a transcoding pipeline (MP4 first), tagging/albums and metadata overrides with audit logging, dedupe + moments, GPS extraction + map UI (no reverse geocoding), endpoint selection for presigned URLs, and CI-based multi-arch builds.
**Tech Stack:** Bun workspaces, Next.js API route handlers (`apps/web/app/api/**/route.ts`), Node worker + BullMQ (`apps/worker/src/jobs.ts`), Postgres migrations (`packages/db/migrations/*.sql`), MinIO (S3) clients (`packages/minio/src/index.ts`), Helm (`helm/porthole/*`).
## Preconditions / Ground Rules
- Do not mutate or delete anything under `originals/`.
- Prefer additive schema changes first; deprecate old columns after compatibility is maintained.
- Use Buns test runner (`bun test`) for new TypeScript tests.
- Keep Pi constraints: CPU-heavy work stays in worker; keep transcoding concurrency low.
## Phase 0: Test Harness + Repo Hygiene
### Task 0.1: Add Bun test runner scripts
**Files:**
- Modify: `package.json`
- Create: `apps/web/src/__tests__/smoke.test.ts`
**Step 1: Write failing test**
Create `apps/web/src/__tests__/smoke.test.ts`:
```ts
import { test, expect } from "bun:test";
test("bun test runs", () => {
expect(1 + 1).toBe(2);
});
```
**Step 2: Run test to verify it fails**
Run: `bun test`
Expected: FAIL (no tests configured / command missing)
**Step 3: Write minimal implementation**
Add script to `package.json`:
```json
{
"scripts": {
"test": "bun test"
}
}
```
**Step 4: Run test to verify it passes**
Run: `bun test`
Expected: PASS
**Step 5: Commit**
```bash
git add package.json apps/web/src/__tests__/smoke.test.ts
git commit -m "test: add bun test runner"
```
## Phase 1: Shared-Secret Admin Auth (Write Protection)
### Task 1.1: Add ADMIN_TOKEN env + helpers
**Files:**
- Modify: `packages/config/src/index.ts`
- Create: `packages/config/src/adminAuth.ts`
- Test: `packages/config/src/adminAuth.test.ts`
**Step 1: Write failing test**
Create `packages/config/src/adminAuth.test.ts`:
```ts
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);
});
```
**Step 2: Run test to verify it fails**
Run: `bun test packages/config/src/adminAuth.test.ts`
Expected: FAIL (module missing)
**Step 3: Write minimal implementation**
Create `packages/config/src/adminAuth.ts`:
```ts
export function isAdminRequest(
env: { adminToken: string | undefined },
input: { headerToken: string | null | undefined },
) {
if (!env.adminToken) return false;
return input.headerToken === env.adminToken;
}
```
Extend `packages/config/src/index.ts` to parse `ADMIN_TOKEN` (optional) and export `getAdminToken()`.
**Step 4: Run test to verify it passes**
Run: `bun test packages/config/src/adminAuth.test.ts`
Expected: PASS
**Step 5: Commit**
```bash
git add packages/config/src/index.ts packages/config/src/adminAuth.ts packages/config/src/adminAuth.test.ts
git commit -m "feat: add admin token config and auth helper"
```
### Task 1.2: Enforce admin on mutation API routes
**Files:**
- Modify: `apps/web/app/api/imports/route.ts`
- Modify: `apps/web/app/api/imports/[id]/upload/route.ts`
- Modify: `apps/web/app/api/imports/[id]/scan-minio/route.ts`
- Test: `apps/web/src/__tests__/admin-gates-imports.test.ts`
**Step 1: Write failing test**
Create `apps/web/src/__tests__/admin-gates-imports.test.ts`:
```ts
import { test, expect } from "bun:test";
// This test intentionally asserts the handler behavior by calling the route function.
// It will require exporting a small pure helper from each route in the implementation.
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);
});
```
**Step 2: Run test to verify it fails**
Run: `bun test apps/web/src/__tests__/admin-gates-imports.test.ts`
Expected: FAIL (handlers module missing)
**Step 3: Write minimal implementation**
- Create `apps/web/app/api/imports/handlers.ts` exporting pure functions that return `{ status, body }` for tests.
- Update `apps/web/app/api/imports/route.ts` to:
- read `X-Porthole-Admin-Token`
- compute adminOk via `@tline/config` helper
- reject with 401 `{ error: "admin_required" }` when not admin
- Repeat pattern for upload + scan routes.
**Step 4: Run test to verify it passes**
Run: `bun test apps/web/src/__tests__/admin-gates-imports.test.ts`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/web/app/api/imports/route.ts apps/web/app/api/imports/handlers.ts \
apps/web/app/api/imports/[id]/upload/route.ts apps/web/app/api/imports/[id]/scan-minio/route.ts \
apps/web/src/__tests__/admin-gates-imports.test.ts
git commit -m "feat: require admin token for ingestion endpoints"
```
## Phase 2: Derived Variants Model (Thumbs/Posters/Video)
### Task 2.1: Add derived variants table + minimal writer/reader
**Files:**
- Create: `packages/db/migrations/0003_asset_variants.sql`
- Modify: `apps/web/app/api/assets/[id]/url/route.ts`
- Modify: `apps/worker/src/jobs.ts`
- Test: `apps/web/src/__tests__/variant-url-404.test.ts`
**Schema (migration):**
```sql
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);
```
**Step 1: Write failing test**
Create `apps/web/src/__tests__/variant-url-404.test.ts`:
```ts
import { test, expect } from "bun:test";
test("/api/assets/:id/url returns 404 when requested variant missing", async () => {
const { pickVariantKey } =
await import("../../app/api/assets/[id]/url/variant");
const key = pickVariantKey({ variants: [] }, { kind: "thumb", size: 256 });
expect(key).toBeNull();
});
```
**Step 2: Run test to verify it fails**
Run: `bun test apps/web/src/__tests__/variant-url-404.test.ts`
Expected: FAIL (module missing)
**Step 3: Write minimal implementation**
- Create `apps/web/app/api/assets/[id]/url/variant.ts`:
```ts
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;
}
```
- Update `apps/web/app/api/assets/[id]/url/route.ts` to support query:
- `kind=original|thumb|poster|video_mp4`
- `size=<int>` (required for non-original)
- Keep backward-compatible `variant=thumb_small|thumb_med|poster|original` for now.
- Update `apps/worker/src/jobs.ts` to insert rows into `asset_variants` when it uploads thumbs/posters.
**Step 4: Run test to verify it passes**
Run: `bun test apps/web/src/__tests__/variant-url-404.test.ts`
Expected: PASS
**Step 5: Commit**
```bash
git add packages/db/migrations/0003_asset_variants.sql \
apps/web/app/api/assets/[id]/url/route.ts apps/web/app/api/assets/[id]/url/variant.ts \
apps/web/src/__tests__/variant-url-404.test.ts apps/worker/src/jobs.ts
git commit -m "feat: add asset variants table and URL selection"
```
### Task 2.2: Multiple thumb + poster sizes
**Files:**
- Modify: `apps/worker/src/jobs.ts`
- Modify: `apps/web/app/api/assets/[id]/url/route.ts`
- Test: `apps/worker/src/__tests__/variants-sizes.test.ts`
**Step 1: Write failing test**
Create `apps/worker/src/__tests__/variants-sizes.test.ts`:
```ts
import { test, expect } from "bun:test";
import { computeImageVariantPlan } from "../variants";
test("computeImageVariantPlan includes 256 and 768 thumbs", () => {
expect(computeImageVariantPlan()).toEqual([
{ kind: "thumb", size: 256 },
{ kind: "thumb", size: 768 },
]);
});
```
**Step 2: Run test to verify it fails**
Run: `bun test apps/worker/src/__tests__/variants-sizes.test.ts`
Expected: FAIL (module missing)
**Step 3: Write minimal implementation**
- Create `apps/worker/src/variants.ts` with exported `computeImageVariantPlan()` and `computeVideoPosterPlan()`.
- Refactor `apps/worker/src/jobs.ts` to use these plans and generate additional poster size(s) (e.g. 256 + 768).
- Insert each uploaded object into `asset_variants` with (kind,size,key,mime_type,width,height).
**Step 4: Run test to verify it passes**
Run: `bun test apps/worker/src/__tests__/variants-sizes.test.ts`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/worker/src/jobs.ts apps/worker/src/variants.ts apps/worker/src/__tests__/variants-sizes.test.ts
git commit -m "feat: generate multiple thumbs and posters"
```
## Phase 3: Video Transcoding + Prefer-Derived Playback
### Task 3.1: Add MP4 transcode worker job
**Files:**
- Modify: `packages/queue/src/index.ts`
- Modify: `apps/worker/src/jobs.ts`
- Test: `apps/worker/src/__tests__/transcode-plan.test.ts`
**Step 1: Write failing test**
Create `apps/worker/src/__tests__/transcode-plan.test.ts`:
```ts
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);
});
```
**Step 2: Run test to verify it fails**
Run: `bun test apps/worker/src/__tests__/transcode-plan.test.ts`
Expected: FAIL
**Step 3: Write minimal implementation**
- Create `apps/worker/src/transcode.ts` implementing `shouldTranscodeToMp4`.
- Add BullMQ job payload + enqueue helper (e.g. `enqueueTranscodeVideoMp4({ assetId })`).
- In `handleProcessAsset` for video, enqueue mp4 transcode when needed.
- Implement ffmpeg transcode to `derived/video/${assetId}/mp4_720p.mp4` (H.264 + AAC, fast preset).
- Insert into `asset_variants` as `kind='video_mp4', size=720, mime_type='video/mp4'`.
- Keep concurrency low (1) in worker for transcodes.
**Step 4: Run test to verify it passes**
Run: `bun test apps/worker/src/__tests__/transcode-plan.test.ts`
Expected: PASS
**Step 5: Commit**
```bash
git add packages/queue/src/index.ts apps/worker/src/jobs.ts apps/worker/src/transcode.ts \
apps/worker/src/__tests__/transcode-plan.test.ts
git commit -m "feat: add mp4 transcode job and variant record"
```
### Task 3.2: Prefer derived in URL endpoint + viewer
**Files:**
- Modify: `apps/web/app/api/assets/[id]/url/route.ts`
- Modify: `apps/web/app/components/MediaPanel.tsx`
- Modify: `apps/web/app/components/Viewer.tsx`
- Test: `apps/web/src/__tests__/prefer-derived.test.ts`
**Step 1: Write failing test**
Create `apps/web/src/__tests__/prefer-derived.test.ts`:
```ts
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 });
});
```
**Step 2: Run test to verify it fails**
Run: `bun test apps/web/src/__tests__/prefer-derived.test.ts`
Expected: FAIL
**Step 3: Write minimal implementation**
- Create `apps/web/app/lib/playback.ts` implementing deterministic selection.
- Update viewer to:
- ask server for `kind=video_mp4&size=720` first
- fall back to `original`
- Keep existing poster behavior.
**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/api/assets/[id]/url/route.ts apps/web/app/lib/playback.ts \
apps/web/app/components/MediaPanel.tsx apps/web/app/components/Viewer.tsx \
apps/web/src/__tests__/prefer-derived.test.ts
git commit -m "feat: prefer derived mp4 playback with fallback"
```
## Phase 4: Tags + Albums
### Task 4.1: Schema for tags/albums + audit log
**Files:**
- Create: `packages/db/migrations/0004_tags_albums_audit.sql`
**Migration (example):**
```sql
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()
);
```
**Verification:** run migrator (k8s migrate job or local script) and ensure no SQL errors.
**Commit:** `git commit -m "feat: add tags, albums, and audit log tables"`
### Task 4.2: Admin API for tags and albums
**Files:**
- Create: `apps/web/app/api/tags/route.ts`
- Create: `apps/web/app/api/albums/route.ts`
- Create: `apps/web/app/api/albums/[id]/assets/route.ts`
- Test: `apps/web/src/__tests__/tags-admin-auth.test.ts`
**Steps:**
- RED: test that POST without admin returns 401
- GREEN: implement CRUD (minimal: list + create; album add/remove assets)
- REFACTOR: write audit_log rows on each mutation
**Commit:** `feat: add admin tags and albums APIs`
### Task 4.3: UI wiring for tags/albums
**Files:**
- Modify: `apps/web/app/admin/page.tsx`
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Steps:**
- Add minimal admin form to set admin token in browser (sessionStorage) and to create/list tags and albums.
- Add UI on asset detail to assign tags, and to add asset to album.
- Keep UX resilient (errors render inline, dont crash).
**Commit:** `feat: add tags/albums UI`
## Phase 5: Metadata Overrides + Timeline Uses Overrides
### Task 5.1: Override table + API
**Files:**
- Create: `packages/db/migrations/0005_asset_overrides.sql`
- Create: `apps/web/app/api/assets/[id]/override-capture-ts/route.ts`
- Modify: `apps/web/app/api/tree/route.ts`
- Modify: `apps/web/app/api/assets/route.ts`
**Migration:** table `asset_overrides(asset_id PK, capture_ts_utc_override timestamptz, capture_offset_minutes_override int, created_at...)`.
**Steps:**
- RED: test route rejects without admin
- GREEN: implement POST to set override and insert audit_log
- GREEN: update aggregation queries to use `COALESCE(overrides.capture_ts_utc_override, assets.capture_ts_utc)`
**Commit:** `feat: add capture time overrides and apply in queries`
### Task 5.2: UI for capture-time override
**Files:**
- Modify: `apps/web/app/components/Viewer.tsx`
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Steps:**
- Add form to set ISO timestamp override and submit to API.
- Display current effective timestamp and base timestamp.
**Commit:** `feat: add UI for capture time override`
## Phase 6: GPS Extraction + Map UI (No Reverse Geocode)
### Task 6.1: Add gps fields + extraction
**Files:**
- Create: `packages/db/migrations/0006_assets_gps.sql`
- Modify: `apps/worker/src/jobs.ts`
**Steps:**
- Add columns `gps_lat double precision`, `gps_lon double precision` (nullable)
- Parse ExifTool GPS fields for images (and where available for videos) and store them.
**Commit:** `feat: extract and store GPS coords`
### Task 6.2: Map UI
**Files:**
- Create: `apps/web/app/map/page.tsx`
- Modify: `apps/web/app/page.tsx`
**Steps:**
- Show a simple map view with markers for assets that have GPS.
- If tiles unavailable, show a clear fallback message.
**Commit:** `feat: add map page for GPS assets`
## Phase 7: Dedupe by Hash + Moments
### Task 7.1: Hash table + compute sha256
**Files:**
- Create: `packages/db/migrations/0007_asset_hashes.sql`
- Modify: `apps/worker/src/jobs.ts`
**Steps:**
- During download to temp file, compute sha256 and store it.
- Add unique index on `(bucket, sha256)` optionally (careful for partial/unknown).
**Commit:** `feat: compute asset sha256 for dedupe`
### Task 7.2: Dedupe detection + API
**Files:**
- Create: `apps/web/app/api/assets/[id]/dupes/route.ts`
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Steps:**
- Endpoint returns assets with same sha256.
- UI indicates duplicates.
**Commit:** `feat: expose and display duplicates`
### Task 7.3: Moments clustering
**Files:**
- Create: `apps/web/app/api/moments/route.ts`
- Create: `apps/web/app/lib/moments.ts`
- Test: `apps/web/src/__tests__/moments.test.ts`
**Steps:**
- RED: test that assets within 30 minutes cluster together
- GREEN: implement clustering
- Wire UI to show moments as sub-groups
**Commit:** `feat: add day moments clustering`
## Phase 8: Presign Endpoint Selection (LAN vs Tailnet)
### Task 8.1: Add endpoint mode to config and presign
**Files:**
- Modify: `packages/minio/src/index.ts`
- Modify: `packages/config/src/index.ts`
- Modify: `apps/web/app/api/assets/[id]/url/route.ts`
**Steps:**
- Add env for `MINIO_PUBLIC_ENDPOINT_LAN` and `MINIO_ENDPOINT_MODE=tailnet|lan|auto`.
- If `endpoint=lan|tailnet` query param is provided, force that.
- In `auto`, use tailnet as safe default.
**Commit:** `feat: support lan/tailnet endpoint selection for presigned URLs`
## Phase 9: Storage Policies (Derived Lifecycle) + CI Builds
### Task 9.1: Optional MinIO lifecycle policy job
**Files:**
- Modify: `helm/porthole/values.yaml`
- Modify: `helm/porthole/templates/job-ensure-bucket.yaml.tpl`
- Create: `helm/porthole/templates/job-apply-lifecycle.yaml.tpl`
**Steps:**
- Add optional Job to apply lifecycle rules for prefixes `thumbs/` and `derived/` (expire after N days) without touching `originals/`.
**Commit:** `feat: add optional lifecycle policy job`
### Task 9.2: Add CI pipeline for multi-arch builds
**Files:**
- Create: `.gitea/workflows/build-images.yml` (or alternative supported by your CI)
- Modify: `README.md`
**Steps:**
- Build and push multi-arch images for `apps/web` and `apps/worker`.
- Run: `bun run typecheck`.
- Run: `bash run_tests.sh` (Go tests) to keep repo green.
**Commit:** `ci: build and push multi-arch images`
## Verification Checklist (Per Phase)
- `bun test`
- `bun run typecheck`
- `bash run_tests.sh`
- Helm template renders: `helm template porthole helm/porthole -f your-values.yaml --namespace porthole`
@@ -1,109 +0,0 @@
# Use Playback Selector Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add an asset variants endpoint and wire MediaPanel to use `pickVideoPlaybackVariant` for derived MP4 selection with a safe fallback.
**Architecture:** Introduce a minimal `/api/assets/:id/variants` route that returns `{ kind, size, key }` from `asset_variants`. MediaPanel fetches variants on-demand for videos, uses `pickVideoPlaybackVariant` to decide whether to request `video_mp4` (size 720), and falls back to original if the derived URL fails.
**Tech Stack:** Next.js App Router API routes, Postgres via `@tline/db`, Bun test runner.
### Task 1: Add variants API route
**Files:**
- Create: `apps/web/app/api/assets/[id]/variants/route.ts`
- Test: `apps/web/src/__tests__/variants-route.test.ts`
**Step 1: Write the failing test**
Create `apps/web/src/__tests__/variants-route.test.ts`:
```ts
import { test, expect } from "bun:test";
test("variants route returns only kind/size/key fields", async () => {
const { shapeVariants } = await import("../../app/api/assets/[id]/variants/shape");
const rows = [
{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4", mime_type: "video/mp4" },
];
expect(shapeVariants(rows)).toEqual([{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4" }]);
});
```
**Step 2: Run test to verify it fails**
Run: `bun test apps/web/src/__tests__/variants-route.test.ts`
Expected: FAIL (missing module or function)
**Step 3: Write minimal implementation**
- Create `apps/web/app/api/assets/[id]/variants/shape.ts` with `shapeVariants(rows)` that returns `{ kind, size, key }` only.
- Create `apps/web/app/api/assets/[id]/variants/route.ts`:
- Validate `id` with `z.string().uuid()`
- Query `asset_variants` by `asset_id`
- Return JSON array of `shapeVariants(rows)`
**Step 4: Run test to verify it passes**
Run: `bun test apps/web/src/__tests__/variants-route.test.ts`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/web/app/api/assets/[id]/variants/route.ts apps/web/app/api/assets/[id]/variants/shape.ts \
apps/web/src/__tests__/variants-route.test.ts
git commit -m "feat: add asset variants endpoint"
```
### Task 2: Use playback selector in MediaPanel
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
- Modify: `apps/web/app/lib/playback.ts`
- Test: `apps/web/src/__tests__/prefer-derived.test.ts`
**Step 1: Write the failing test**
Add to `apps/web/src/__tests__/prefer-derived.test.ts`:
```ts
import { test, expect } from "bun:test";
import { pickVideoPlaybackVariant } from "../../app/lib/playback";
test("pickVideoPlaybackVariant returns null when no variants", () => {
expect(
pickVideoPlaybackVariant({
originalMimeType: "video/x-matroska",
variants: [],
}),
).toBeNull();
});
```
**Step 2: Run test to verify it fails**
Run: `bun test apps/web/src/__tests__/prefer-derived.test.ts`
Expected: FAIL (function does not handle empty variants)
**Step 3: Write minimal implementation**
- Update `pickVideoPlaybackVariant` to return `null` when no `video_mp4` variants exist.
- Update `MediaPanel` video URL loader to:
1) Fetch `/api/assets/:id/variants`
2) Call `pickVideoPlaybackVariant`
3) If variant found → request `kind=video_mp4&size=720`
4) If not found or fetch fails → request `variant=original`
**Step 4: Run test to verify it passes**
Run: `bun test apps/web/src/__tests__/prefer-derived.test.ts`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/web/app/components/MediaPanel.tsx apps/web/app/lib/playback.ts \
apps/web/src/__tests__/prefer-derived.test.ts
git commit -m "fix: use playback selector in MediaPanel"
```
-89
View File
@@ -1,89 +0,0 @@
# 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"
```
@@ -1,138 +0,0 @@
# Capture Time Override UI Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a capture-time override form in the MediaPanel viewer to POST override timestamps and display current effective/base timestamps.
**Architecture:** Extend the existing MediaPanel viewer admin controls with a small form that reads/writes to `/api/assets/:id/override-capture-ts` using the existing admin token from sessionStorage. Keep UI state local to MediaPanel and refresh the viewer asset timestamps after submit.
**Tech Stack:** React (Next.js app router), TypeScript, fetch API
### Task 1: Add override state and helpers
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Step 1: Write the failing test**
No tests (user-approved).
**Step 2: Add state for override input, status, and effective/base timestamps**
```ts
const [captureOverrideInput, setCaptureOverrideInput] = useState("");
const [captureOverrideError, setCaptureOverrideError] = useState<string | null>(null);
const [captureOverrideBusy, setCaptureOverrideBusy] = useState(false);
```
**Step 3: Add helper to derive effective/base timestamp**
```ts
const effectiveTs = viewer?.asset.capture_ts_utc ?? null;
const baseTs = viewer?.asset.capture_ts_utc ?? null; // updated when override applied
```
**Step 4: Commit**
No commit yet; continue tasks.
### Task 2: Add override POST handler
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Step 1: Write the failing test**
No tests (user-approved).
**Step 2: Implement submit handler**
```ts
async function handleOverrideCaptureTs() {
if (!viewer) return;
setCaptureOverrideError(null);
setCaptureOverrideBusy(true);
try {
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
if (!token) throw new Error("missing_admin_token");
const res = await fetch(`/api/assets/${viewer.asset.id}/override-capture-ts`, {
method: "POST",
headers: {
"X-Porthole-Admin-Token": token,
"Content-Type": "application/json",
},
body: JSON.stringify({ capture_ts_utc: captureOverrideInput || null }),
});
if (!res.ok) throw new Error(`override_failed:${res.status}`);
// refresh viewer asset timestamps (re-fetch list or update local)
} catch (err) {
setCaptureOverrideError(err instanceof Error ? err.message : String(err));
} finally {
setCaptureOverrideBusy(false);
}
}
```
**Step 3: Commit**
No commit yet; continue tasks.
### Task 3: Add UI above Tags & Albums
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Step 1: Write the failing test**
No tests (user-approved).
**Step 2: Add form UI**
```tsx
<div style={{ display: "grid", gap: 6 }}>
<strong style={{ fontSize: 13 }}>Capture time override</strong>
<div style={{ color: "#666", fontSize: 12 }}>
Effective: {effectiveTs ?? "(none)"}
</div>
<div style={{ color: "#999", fontSize: 12 }}>
Base: {baseTs ?? "(unknown)"}
</div>
<div style={{ display: "flex", gap: 8 }}>
<input
type="text"
placeholder="2026-01-01T00:00:00.000Z"
value={captureOverrideInput}
onChange={(e) => setCaptureOverrideInput(e.target.value)}
style={{ flex: 1, padding: 6 }}
disabled={captureOverrideBusy}
/>
<button type="button" onClick={handleOverrideCaptureTs}>
Save
</button>
</div>
{captureOverrideError ? (
<div style={{ color: "#b00", fontSize: 12 }}>{captureOverrideError}</div>
) : null}
</div>
```
**Step 3: Commit**
No commit yet; continue tasks.
### Task 4: Finalize, verify, and commit
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Step 1: Quick manual check**
Run: `npm test` (skip)
Expected: (skipped per user)
**Step 2: Commit**
```bash
git add apps/web/app/components/MediaPanel.tsx
git commit -m "feat: add UI for capture time override"
```
@@ -1,73 +0,0 @@
{{- 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 }}
+12 -4
View File
@@ -11,15 +11,23 @@ secrets:
accessKeyId: "FwGiBwCXKuQdthR1QLa"
secretAccessKey: "OtmAz9m7o941wG1Gms2yItyqxd6gCWY8k4LJVBX"
# Temporarily disable jobs and apps until images are built
# Enable jobs and apps now that images are built
jobs:
migrate:
enabled: false
enabled: true
web:
enabled: false
enabled: true
worker:
enabled: false
enabled: true
# Correct image registry (gitea-http not gitea-gitea-http)
images:
web:
repository: gitea-http.taildb3494.ts.net/will/porthole-web
worker:
repository: gitea-http.taildb3494.ts.net/will/porthole-worker
# Reduce MinIO storage for testing (insufficient storage on cluster)
minio:
-18
View File
@@ -231,24 +231,6 @@ jobs:
cpu: 300m
memory: 256Mi
applyLifecycle:
enabled: false
expire_days: 30
prefixes:
thumbs: thumbs/
derived: derived/
image:
repository: minio/mc
tag: RELEASE.2024-01-16T16-07-38Z
pullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 300m
memory: 256Mi
migrate:
enabled: true
image:
-4
View File
@@ -9,13 +9,11 @@
"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",
@@ -25,8 +23,6 @@
"typescript": "^5.9.3"
},
"dependencies": {
"leaflet": "^1.9.4",
"react-leaflet": "^5.0.0",
"zod": "^4.2.1"
}
}
-14
View File
@@ -1,14 +0,0 @@
import { test, expect } from "bun:test";
import { isAdminRequest } from "./adminAuth";
test("isAdminRequest returns false when ADMIN_TOKEN unset", () => {
expect(isAdminRequest({ adminToken: undefined }, { headerToken: "x" })).toBe(
false,
);
});
test("isAdminRequest returns true when header token matches", () => {
expect(
isAdminRequest({ adminToken: "secret" }, { headerToken: "secret" }),
).toBe(true);
});
-7
View File
@@ -1,7 +0,0 @@
export function isAdminRequest(
env: { adminToken: string | undefined },
input: { headerToken: string | null | undefined },
) {
if (!env.adminToken) return false;
return input.headerToken === env.adminToken;
}
+1 -21
View File
@@ -1,13 +1,8 @@
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(),
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"),
NEXT_PUBLIC_APP_NAME: z.string().min(1).optional()
});
let cachedEnv: z.infer<typeof envSchema> | undefined;
@@ -28,18 +23,3 @@ 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;
}
@@ -1,20 +0,0 @@
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);
@@ -1,34 +0,0 @@
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()
);
@@ -1,9 +0,0 @@
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);
@@ -1,3 +0,0 @@
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS gps_lat double precision,
ADD COLUMN IF NOT EXISTS gps_lon double precision;
@@ -1,9 +0,0 @@
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;
@@ -1,52 +0,0 @@
import { expect, test } from "bun:test";
import { resolvePresignEndpoint } from "./endpointSelector";
import type { MinioEnv } from "./env";
const baseEnv: MinioEnv = {
MINIO_INTERNAL_ENDPOINT: "http://minio:9000",
MINIO_PUBLIC_ENDPOINT_TS: "https://ts.example.com",
MINIO_PUBLIC_ENDPOINT_LAN: "https://lan.example.com",
MINIO_ACCESS_KEY_ID: "key",
MINIO_SECRET_ACCESS_KEY: "secret",
MINIO_REGION: "us-east-1",
MINIO_BUCKET: "media",
MINIO_PRESIGN_EXPIRES_SECONDS: 900,
MINIO_ENDPOINT_MODE: "auto",
};
test("auto endpoint mode defaults to tailnet", () => {
expect(resolvePresignEndpoint(baseEnv, undefined)).toBe(
"https://ts.example.com",
);
});
test("endpoint=lan forces LAN endpoint", () => {
expect(resolvePresignEndpoint(baseEnv, "lan")).toBe(
"https://lan.example.com",
);
});
test("endpoint=tailnet forces tailnet endpoint", () => {
expect(resolvePresignEndpoint(baseEnv, "tailnet")).toBe(
"https://ts.example.com",
);
});
test("lan mode selects LAN endpoint", () => {
const env = { ...baseEnv, MINIO_ENDPOINT_MODE: "lan" as const };
expect(resolvePresignEndpoint(env, undefined)).toBe(
"https://lan.example.com",
);
});
test("lan mode without LAN endpoint throws", () => {
const env = {
...baseEnv,
MINIO_ENDPOINT_MODE: "lan" as const,
MINIO_PUBLIC_ENDPOINT_LAN: undefined,
};
expect(() => resolvePresignEndpoint(env, undefined)).toThrow(
"MINIO_PUBLIC_ENDPOINT_LAN is required",
);
});
-22
View File
@@ -1,22 +0,0 @@
import type { MinioEnv } from "./env";
export type PresignEndpointOverride = "lan" | "tailnet";
export function resolvePresignEndpoint(
env: MinioEnv,
override?: PresignEndpointOverride,
) {
const mode = override ?? env.MINIO_ENDPOINT_MODE;
if (mode === "lan") {
if (!env.MINIO_PUBLIC_ENDPOINT_LAN) {
throw new Error("MINIO_PUBLIC_ENDPOINT_LAN is required for lan endpoint mode");
}
return env.MINIO_PUBLIC_ENDPOINT_LAN;
}
if (!env.MINIO_PUBLIC_ENDPOINT_TS) {
throw new Error(
"MINIO_PUBLIC_ENDPOINT_TS is required for presigned URL generation",
);
}
return env.MINIO_PUBLIC_ENDPOINT_TS;
}
-31
View File
@@ -1,31 +0,0 @@
import { z } from "zod";
export const envSchema = z.object({
MINIO_INTERNAL_ENDPOINT: z.string().url().optional(),
MINIO_PUBLIC_ENDPOINT_TS: z.string().url().optional(),
MINIO_PUBLIC_ENDPOINT_LAN: z.string().url().optional(),
MINIO_ACCESS_KEY_ID: z.string().min(1),
MINIO_SECRET_ACCESS_KEY: z.string().min(1),
MINIO_REGION: z.string().min(1).default("us-east-1"),
MINIO_BUCKET: z.string().min(1).default("media"),
MINIO_PRESIGN_EXPIRES_SECONDS: z.coerce
.number()
.int()
.positive()
.default(900),
MINIO_ENDPOINT_MODE: z.enum(["tailnet", "lan", "auto"]).default("auto"),
});
export type MinioEnv = z.infer<typeof envSchema>;
let cachedEnv: MinioEnv | undefined;
export function getMinioEnv(): MinioEnv {
if (cachedEnv) return cachedEnv;
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(`Invalid MinIO env: ${parsed.error.message}`);
}
cachedEnv = parsed.data;
return cachedEnv;
}
+35 -22
View File
@@ -2,16 +2,33 @@ import "server-only";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getMinioEnv, type MinioEnv } from "./env";
import {
resolvePresignEndpoint,
type PresignEndpointOverride,
} from "./endpointSelector";
import { z } from "zod";
const envSchema = z.object({
MINIO_INTERNAL_ENDPOINT: z.string().url().optional(),
MINIO_PUBLIC_ENDPOINT_TS: z.string().url().optional(),
MINIO_ACCESS_KEY_ID: z.string().min(1),
MINIO_SECRET_ACCESS_KEY: z.string().min(1),
MINIO_REGION: z.string().min(1).default("us-east-1"),
MINIO_BUCKET: z.string().min(1).default("media"),
MINIO_PRESIGN_EXPIRES_SECONDS: z.coerce.number().int().positive().default(900)
});
type MinioEnv = z.infer<typeof envSchema>;
let cachedEnv: MinioEnv | undefined;
let cachedInternal: S3Client | undefined;
let cachedPublic: S3Client | undefined;
export type { MinioEnv, PresignEndpointOverride };
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 function getMinioBucket() {
return getMinioEnv().MINIO_BUCKET;
@@ -37,27 +54,24 @@ export function getMinioInternalClient(): S3Client {
return cachedInternal;
}
export function getMinioPublicSigningClient(
override?: PresignEndpointOverride,
): S3Client {
if (!override && cachedPublic) return cachedPublic;
export function getMinioPublicSigningClient(): S3Client {
if (cachedPublic) return cachedPublic;
const env = getMinioEnv();
const endpoint = resolvePresignEndpoint(env, override);
const client = new S3Client({
if (!env.MINIO_PUBLIC_ENDPOINT_TS) {
throw new Error("MINIO_PUBLIC_ENDPOINT_TS is required for presigned URL generation");
}
cachedPublic = new S3Client({
region: env.MINIO_REGION,
endpoint,
endpoint: env.MINIO_PUBLIC_ENDPOINT_TS,
forcePathStyle: true,
credentials: {
accessKeyId: env.MINIO_ACCESS_KEY_ID,
secretAccessKey: env.MINIO_SECRET_ACCESS_KEY,
},
secretAccessKey: env.MINIO_SECRET_ACCESS_KEY
}
});
if (!override) {
cachedPublic = client;
}
return client;
return cachedPublic;
}
export async function presignGetObjectUrl(input: {
@@ -66,10 +80,9 @@ export async function presignGetObjectUrl(input: {
expiresSeconds?: number;
responseContentType?: string;
responseContentDisposition?: string;
endpoint?: PresignEndpointOverride;
}) {
const env = getMinioEnv();
const s3 = getMinioPublicSigningClient(input.endpoint);
const s3 = getMinioPublicSigningClient();
const command = new GetObjectCommand({
Bucket: input.bucket,
+2 -20
View File
@@ -11,8 +11,7 @@ const envSchema = z.object({
export const jobNameSchema = z.enum([
"scan_minio_prefix",
"process_asset",
"copy_to_canonical",
"transcode_video_mp4"
"copy_to_canonical"
]);
export type QueueJobName = z.infer<typeof jobNameSchema>;
@@ -37,23 +36,15 @@ 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("transcode_video_mp4"), payload: transcodeVideoMp4PayloadSchema })
z.object({ name: z.literal("copy_to_canonical"), payload: copyToCanonicalPayloadSchema })
]);
export type ScanMinioPrefixPayload = z.infer<typeof scanMinioPrefixPayloadSchema>;
export type ProcessAssetPayload = z.infer<typeof processAssetPayloadSchema>;
export type CopyToCanonicalPayload = z.infer<typeof copyToCanonicalPayloadSchema>;
export type TranscodeVideoMp4Payload = z.infer<typeof transcodeVideoMp4PayloadSchema>;
type QueueEnv = z.infer<typeof envSchema>;
@@ -135,12 +126,3 @@ 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 }
});
}