Compare commits
3 Commits
main
..
901986f84f
| Author | SHA1 | Date | |
|---|---|---|---|
| 901986f84f | |||
| f7b93dc284 | |||
| 360998d064 |
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# porthole
|
||||
|
||||
[](/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
@@ -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 });
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { getAppName } from "@tline/config";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
export const metadata = {
|
||||
title: getAppName()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function shouldTranscodeToMp4(input: { mimeType: string }) {
|
||||
return input.mimeType !== "video/mp4";
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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 Bun’s 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, don’t 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"
|
||||
```
|
||||
@@ -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 }}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,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",
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user