# 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_NAME` everywhere (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 from `APP_NAME`). ### Networking - Tailnet clients access the app via **Tailscale Ingress HTTPS termination**. - MinIO is reachable **over tailnet** via a dedicated FQDN: - `https://minio.` (S3 API) - `https://minio-console.` (MinIO console) - App is reachable over tailnet: - `https://app.` - Optional LAN ingress exists using `nip.io` and 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.` - 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.jpg` and `image_768.jpg`. - Video posters: only `poster_256.jpg` initially (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 1. Ingestion (upload or scan) creates/updates DB asset records. 2. Worker extracts metadata and generates thumbs/posters. 3. UI queries aggregated timeline nodes and displays a tree. 4. 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.jpg` - `thumbs/{assetId}/image_768.jpg` - `thumbs/{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`) - `status` - `created_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 `assets` by `source_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_time` and technical metadata. - Derived generation: - Images: `sharp` → `image_256.jpg`, `image_768.jpg`. - Videos: `ffmpeg` screenshot → `poster_256.jpg`. - 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_key` for external archive. - Updates `canonical_key` and flips `active_key`. --- ## API (MVP) ### Admin ingestion - `POST /api/imports` → create import batch - `POST /api/imports/:id/upload` → upload media to `staging/` and enqueue processing - `POST /api/imports/:id/scan-minio` → enqueue scan of allowlisted prefix - `GET /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 - `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.` --- ## Frontend UX/UI (MVP) ### Pages - `/` Timeline tree - `/admin` Admin 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 `