- Created comprehensive QA checklist covering edge cases (missing EXIF, timezones, codecs, corrupt files) - Added ErrorBoundary component wrapped around TimelineTree and MediaPanel - Created global error.tsx page for unhandled errors - Improved failed asset UX with red borders, warning icons, and inline error display - Added loading skeletons to TimelineTree and MediaPanel - Added retry button for failed media loads - Created DEPLOYMENT_VALIDATION.md with validation commands and checklist - Applied k8s recommendations: - Changed node affinity to required for compute nodes (Pi 5) - Enabled Tailscale LoadBalancer service for MinIO S3 (reliable Range requests) - Enabled cleanup CronJob for staging files
19 KiB
Timeline Media Library — Implementation Plan
This document defines the conception and execution plan for a web app that ingests photos/videos, extracts metadata (capture date), and displays media on a visual timeline tree that supports vertical and horizontal orientations. The app runs in a Kubernetes cluster (Raspberry Pi heterogenous nodes) with Longhorn PVCs, in-cluster MinIO for storage, and Tailscale Ingress for private HTTPS access.
This plan is written to be executed by multiple subagents (parallelizable workstreams) each using a specific LLM model.
Goals (MVP)
- Index media (photos + videos) and extract capture date metadata.
- Render an interactive timeline tree (Year → Month → Day) with:
- Orientation toggle: vertical/horizontal.
- Zoom/pan (touch + mouse).
- Expand/collapse nodes.
- Provide a mobile-friendly UI with a bottom sheet details panel.
- Support videos (play original if supported; show poster + fallback message otherwise).
- Ingest sources:
- Admin upload (cross-browser; no folder APIs required).
- Server-side scan of MinIO prefix
originals/(external archive).
- Use presigned URLs for media delivery directly from MinIO.
- Be resilient: broken/unsupported media should show placeholders and never break the app.
Non-goals (MVP)
- Authentication/authorization (rely on tailnet perimeter for now).
- Location/map features.
- User edits (fix dates, tagging, etc.).
- Video transcoding (planned as future CronJob).
- Deduplication.
Key Decisions (Locked)
App identity
- App name:
porthole - Set the app name via environment variable:
APP_NAME=porthole. - Use
APP_NAMEeverywhere (web + worker) via the shared config module so renaming is global. - If the UI needs to display the name in the browser, also provide
NEXT_PUBLIC_APP_NAME(either set explicitly or derived at build time fromAPP_NAME).
Networking
- Tailnet clients access the app via Tailscale Ingress HTTPS termination.
- MinIO is reachable over tailnet via a dedicated FQDN:
https://minio.<tailnet-fqdn>(S3 API)https://minio-console.<tailnet-fqdn>(MinIO console)
- App is reachable over tailnet:
https://app.<tailnet-fqdn>
- Optional LAN ingress exists using
nip.ioand nginx ingress, but tailnet clients use Tailscale hostnames.
Storage model
- MinIO is the source of truth.
- External archive objects under
originals/are treated as immutable:- The app indexes in place.
- The app must never delete/mutate external originals.
- Canonical managed library is copy-only, pure date layout:
canonical/originals/YYYY/MM/DD/{assetId}.{origExt}
- Uploads are processed then stored in canonical by default.
Presigned URL strategy
- Use path-style presigned URLs signed against:
MINIO_PUBLIC_ENDPOINT_TS=https://minio.<tailnet-fqdn>
- Using HTTPS for MinIO on tailnet avoids mixed-content block when the app is served via HTTPS.
Kubernetes constraints
- Cluster nodes: 2× Raspberry Pi 5 (8GB) + 1× Raspberry Pi 3 B+ (1GB).
- Heavy pods must be pinned to Pi 5 nodes.
- Multi-arch images required (arm64 + amd64), built on a laptop and pushed to an in-cluster insecure HTTP registry.
Metadata extraction
- Photos: camera-like EXIF first (
DateTimeOriginal), then fallbacks. - Videos: camera-like tags first (ExifTool QuickTime/vendor tags), fallback to universal container
creation_time.
Derived media
- Image thumbs:
image_256.jpgandimage_768.jpg. - Video posters: only
poster_256.jpginitially (CPU-friendly).
Architecture
Components
- Web: Next.js (UI + API)
- Worker: Node worker using BullMQ
- Queue: Redis
- DB: Postgres
- Object store: MinIO (in-cluster, single-node)
Data flow
- Ingestion (upload or scan) creates/updates DB asset records.
- Worker extracts metadata and generates thumbs/posters.
- UI queries aggregated timeline nodes and displays a tree.
- UI fetches presigned URLs for rendering and playback.
MinIO Object Layout (Single Bucket)
Example bucket: media.
- External archive (indexed in place):
originals/**
- Upload staging (temporary):
staging/{importId}/{uuid}.{ext}
- Canonical (copy only):
canonical/originals/YYYY/MM/DD/{assetId}.{origExt}
- Derived thumbnails/posters:
thumbs/{assetId}/image_256.jpgthumbs/{assetId}/image_768.jpgthumbs/{assetId}/poster_256.jpg
- Future derived video transcodes:
derived/video/{assetId}/...
Database Model (MVP)
Table: assets
id(UUID)bucket(text)media_type(image|video)mime_type(text)- Keys:
source_key(text, immutable)active_key(text)canonical_key(text, nullable)
- Time:
capture_ts_utc(timestamptz)capture_offset_minutes(int, nullable)date_confidence(camera|container|object_mtime|import_time)
- Media fields:
width(int, nullable)height(int, nullable)rotation(int, nullable)duration_seconds(int, nullable)
- Derived:
thumb_small_key(text, nullable)thumb_med_key(text, nullable)poster_key(text, nullable)
- Processing:
status(new|processing|ready|failed)error_message(text, nullable)raw_tags_json(jsonb, optional but recommended for debugging)
Indexes:
capture_ts_utc,status,media_type
Table: imports
id(UUID)type(upload|minio_scan|normalize_copy)statuscreated_at- Optional counters for progress reporting.
Worker Jobs (BullMQ)
scan_minio_prefix(importId, bucket, prefix)
- Guardrails: only allow prefixes from allowlist, starting with
originals/. - Lists objects; upserts
assetsbysource_key. - Enqueues
process_asset(assetId).
process_asset(assetId)
- Downloads object (stream or temp file).
- Extracts metadata:
- Photos: ExifTool EXIF chain.
- Videos: ExifTool first; ffprobe fallback for
creation_timeand technical metadata.
- Derived generation:
- Images:
sharp→image_256.jpg,image_768.jpg. - Videos:
ffmpegscreenshot →poster_256.jpg.
- Images:
- Updates DB status.
- Never throws errors that would crash the worker loop; failures are captured on the asset row.
copy_to_canonical(assetId)
- Computes canonical key:
canonical/originals/YYYY/MM/DD/{assetId}.{origExt}. - Copy-only; never deletes
source_keyfor external archive. - Updates
canonical_keyand flipsactive_key.
API (MVP)
Admin ingestion
POST /api/imports→ create import batchPOST /api/imports/:id/upload→ upload media tostaging/and enqueue processingPOST /api/imports/:id/scan-minio→ enqueue scan of allowlisted prefixGET /api/imports/:id/status→ progress
Timeline and browsing
GET /api/tree- params:
start,end,granularity=year|month|day, filters:mediaType - returns nodes with counts and sample thumbs
- params:
GET /api/assets- params: date-range + pagination + filters
GET /api/assets/:id/url?variant=original|thumb_small|thumb_med|poster- returns presigned URL pointing at
https://minio.<tailnet-fqdn>
- returns presigned URL pointing at
Frontend UX/UI (MVP)
Pages
/Timeline tree/adminAdmin tools (upload, scan, import status)
Timeline tree
- SVG tree rendering with:
- Vertical/horizontal orientation toggle.
- Zoom/pan (touch supported).
- Expand/collapse nodes.
- Detail panel:
- Desktop: right-side panel.
- Mobile: bottom sheet.
- Virtualized thumbnail list.
Viewer
- Image viewer modal.
- Video playback via HTML5
<video>on the presigned URL. - If a video can’t be played (codec/container): show poster + message.
Resilience
- Any media with
status=failedrenders as a placeholder tile and does not break aggregation or layout.
Kubernetes Deployment Plan (Pi-aware)
Scheduling
- Label nodes:
- Pi 5 nodes:
node-class=compute - Pi 3 node:
node-class=tiny
- Pi 5 nodes:
- Pin pods to Pi 5:
web,worker,minio,postgres,redis
Workloads
StatefulSet/minio(single-node) + Longhorn PVCStatefulSet/postgres+ Longhorn PVCDeployment/redisDeployment/webDeployment/worker(BullMQ concurrency 1)CronJob/cleanup-staging(optional; disabled by default)
Exposure
- Tailscale Ingress (HTTPS termination):
app.<tailnet-fqdn>→ web serviceminio.<tailnet-fqdn>→ MinIO S3 (9000)minio-console.<tailnet-fqdn>→ MinIO console (9001)
- Optional LAN nginx ingress + MetalLB for
nip.iohostnames.
Ingress notes
- For uploads and media streaming, configure timeouts and body size to support “large but not gigantic” media.
- Ensure Range requests work for video playback.
Build & Release (Multi-arch)
Package manager
- Use Bun for installs and scripts (
bun install,bun run ...). - Avoid
npm/pnpmin CI and docs unless required for a specific tool.
Container build
- Build on laptop using Docker Buildx.
- Push
linux/arm64andlinux/amd64images to local in-cluster registry over insecure HTTP. - Use Debian-slim Node base images for better ARM64 compatibility with
sharp+ ffmpeg.
Execution Plan (Tasks + Subagents)
This plan is intended to be executed in parallel by multiple subagents. Each subagent uses a specific LLM model and follows its brief in ./.agents/.
Git commits (required during development)
- Treat each numbered task below as a development phase.
- At the end of each phase (or a meaningful sub-slice), create a small, scoped git commit that cleanly compiles/tests for that phase.
- Prefer one commit per phase when feasible; use multiple commits if a phase is large, but keep each commit independently reviewable.
- Commit messages should reference the phase, e.g.
task-4: worker pipeline metadata + thumbs. - Never commit secrets (credentials, tailnet hostnames, access keys); use
.env.example/ k8sSecretmanifests or placeholders.
Progress tracking (source of truth)
PLAN.mdis the single source of truth for project status.- Keep the table below updated in every PR/merge/phase-end commit that changes scope or completes work.
- Exactly one task should be marked
in_progressat a time.
| Task | Status | Notes |
|---|---|---|
| 1 — Repository scaffolding | completed | Bun workspace + shared config scaffold |
| 2 — Database schema + migrations | completed | assets/imports schema + migration runner |
| 3 — MinIO client + presigned URL strategy | completed | @tline/minio + presigned URL API route |
| 4 — Worker pipeline (process images/videos) | completed | process_asset + scan_minio_prefix implemented |
| 5 — Ingestion endpoints (upload + scan) | completed | imports create/upload/scan/status APIs |
| 6 — Canonical copy logic (uploads default) | completed | copy_to_canonical worker job + enqueue on uploads |
| 7 — Timeline aggregation API | completed | /api/tree implemented |
| 8 — Timeline tree frontend | completed | basic SVG tree + orientation toggle |
| 9 — Media panel + viewer | completed | day selection, asset list, preview + viewer |
| 10 — k8s deployment (Pi-aware) | completed | Helm chart + Tailscale ingress |
| 11 — QA + hardening | completed | QA checklist created, error boundaries added, UI resilience improved, deployment validation documented, k8s recommendations applied (required affinity, Tailscale LB service, cleanup CronJob enabled) |
- Entry point:
./.agents/README.md - Agent briefs:
./.agents/orchestrator.md./.agents/backend-api.md./.agents/worker-media.md./.agents/frontend-ui.md./.agents/k8s-infra.md./.agents/qa-review.md
Subagents and assigned model
| Subagent | Responsibility | LLM Model |
|---|---|---|
orchestrator |
backlog coordination, interfaces, acceptance criteria | github-copilot/gpt-5.2 |
backend-api |
Next.js API routes, DB schema/migrations, presigned URL logic | github-copilot/claude-sonnet-4.5 |
worker-media |
BullMQ worker, ExifTool/ffprobe/ffmpeg integration, thumbs/posters | github-copilot/claude-sonnet-4.5 |
frontend-ui |
timeline tree rendering, responsive layout, virtualization, styling | github-copilot/gpt-5.2 |
k8s-infra |
Helm/Kustomize, node affinity, MinIO/Postgres/Redis manifests, Tailscale ingress | github-copilot/claude-sonnet-4.5 |
qa-review |
test plan, edge cases, security review, performance checks | github-copilot/claude-haiku-4.5 |
Note: the model names above are intentionally explicit. If your environment exposes different model IDs, replace them consistently.
Task breakdown (MVP)
Task 1 — Repository scaffolding
- Define folder structure (apps/web, apps/worker, helm/).
- Add shared
configmodule (env validation).
Owner: orchestrator (brief: ./.agents/orchestrator.md, model: github-copilot/gpt-5.2)
Task 2 — Database schema + migrations
- Implement
assets/importsschema. - Add indexes.
Owner: backend-api (brief: ./.agents/backend-api.md, model: github-copilot/claude-sonnet-4.5)
Task 3 — MinIO client + presigned URL strategy
- Implement internal client for cluster operations.
- Implement public-signing client for tailnet endpoint.
- Enforce path-style URLs.
Owner: backend-api (brief: ./.agents/backend-api.md, model: github-copilot/claude-sonnet-4.5)
Task 4 — Worker pipeline (process images/videos)
- ExifTool extraction (photos + camera-like video fields).
- ffprobe technical metadata; fallback
creation_time. sharpthumbs for images.- ffmpeg poster extraction for videos.
- Robust failure handling updates DB.
Owner: worker-media (brief: ./.agents/worker-media.md, model: github-copilot/claude-sonnet-4.5)
Task 5 — Ingestion endpoints (upload + scan)
- Admin upload endpoint: stream to
staging/. - Scan endpoint: enqueue
scan_minio_prefixonly for allowlisted prefixoriginals/. - Import status endpoint.
Owner: backend-api (brief: ./.agents/backend-api.md, model: github-copilot/claude-sonnet-4.5)
Task 6 — Canonical copy logic (uploads default)
- For uploads, copy to canonical date key, flip
active_key. - For scans, optional manual/cron copy.
Owner: worker-media (brief: ./.agents/worker-media.md, model: github-copilot/claude-sonnet-4.5)
Task 7 — Timeline aggregation API
GET /api/treefor year/month/day rolling up counts.- Select sample thumbs per node.
Owner: backend-api (brief: ./.agents/backend-api.md, model: github-copilot/claude-sonnet-4.5)
Task 8 — Timeline tree frontend
- Interactive tree with orientation toggle.
- Touch zoom/pan.
- Expand/collapse.
Owner: frontend-ui (brief: ./.agents/frontend-ui.md, model: github-copilot/gpt-5.2)
Task 9 — Media panel + viewer
- Virtualized thumbnail list.
- Viewer modal for images.
- Video playback with poster fallback.
- Placeholder tiles for
failedassets.
Owner: frontend-ui (brief: ./.agents/frontend-ui.md, model: github-copilot/gpt-5.2)
Task 10 — k8s deployment (Pi-aware)
- Helm chart or Kustomize.
- Node affinity to Pi 5 nodes.
- Longhorn PVCs.
- Tailscale ingress resources for app/minio/console.
- CronJob cleanup staging.
Owner: k8s-infra (brief: ./.agents/k8s-infra.md, model: github-copilot/claude-sonnet-4.5)
Task 11 — QA + hardening
- Edge case tests: missing EXIF, odd timezones, unsupported video codecs.
- Validate Range playback through ingress.
- Verify no UI crash on failed assets.
Owner: qa-review (brief: ./.agents/qa-review.md, model: github-copilot/claude-haiku-4.5)
Future Features (Tracked)
Security / Access
- Authentication and authorization.
- Lightweight admin protection (shared secret header) before full auth.
Media
- Video transcoding CronJob (H.264 MP4 and/or HLS) and “prefer derived” playback.
- Multiple poster/thumb sizes.
- Better codec support via transcode profiles.
Organization
- User-defined albums and tags.
- Progressive enhancement for folder upload where supported.
- Bucket separation (
mediavsderived) or lifecycle policies.
Metadata
- Location: GPS extraction + reverse geocoding + map UI.
- Metadata edits/overrides (fix dates, correct capture time), audit log.
Performance / Scale
- Deduplication by hash.
- Smarter clustering (“moments”) within a day.
Networking
- Routed LAN for tailnet clients (subnet router) and endpoint selection for presigned URLs.
Delivery
- Move multi-arch builds from laptop to CI.
Acceptance Criteria Summary
- Can scan
originals/and show a populated timeline. - Can upload media on mobile; it appears under canonical date layout.
- Timeline tree renders and remains responsive on mobile.
- Broken media shows placeholders; the app never crashes.
- Video playback works when codec supported; poster shown otherwise.
- Presigned URLs work over tailnet HTTPS against
minio.<tailnet-fqdn>. - k8s scheduling pins heavy workloads to Pi 5 nodes.