|
@@ -0,0 +1,335 @@
|
|
|
|
|
+# Viewer — Final Design (Go + TypeScript)
|
|
|
|
|
+
|
|
|
|
|
+## 1. Goals
|
|
|
|
|
+
|
|
|
|
|
+1. View a large personal photo archive organized as **ZIP albums** (≈10k ZIPs, ~20 photos each).
|
|
|
|
|
+2. Keep storage efficient on a **home S3-compatible server** that performs poorly with many small objects.
|
|
|
|
|
+3. Provide a **photo-centric UI** with:
|
|
|
|
|
+ - **Page 1:** Random “Pinterest-like” wall (edge-to-edge)
|
|
|
|
|
+ - **Page 2:** Full-screen album viewer (edge-to-edge)
|
|
|
|
|
+4. Avoid a database. Use **S3 as source of truth** + **one metadata JSON per ZIP**.
|
|
|
|
|
+
|
|
|
|
|
+## 2. Non-Goals (for MVP)
|
|
|
|
|
+
|
|
|
|
|
+- Search, tagging, ML classification
|
|
|
|
|
+- Sharing/auth beyond minimal single-user auth
|
|
|
|
|
+- Full offline indexing of every photo ahead of time
|
|
|
|
|
+- Generating/storing per-photo thumbnails as separate S3 objects
|
|
|
|
|
+
|
|
|
|
|
+------
|
|
|
|
|
+
|
|
|
|
|
+## 3. High-Level Architecture
|
|
|
|
|
+
|
|
|
|
|
+### Components
|
|
|
|
|
+
|
|
|
|
|
+1. **Web Frontend (TypeScript)**
|
|
|
|
|
+ - Upload ZIP
|
|
|
|
|
+ - Page 1 random wall
|
|
|
|
|
+ - Page 2 full-screen viewer
|
|
|
|
|
+2. **API Server (Go)**
|
|
|
|
|
+ - Album upload session + finalize
|
|
|
|
|
+ - Generate and store album metadata JSON
|
|
|
|
|
+ - Random feed API
|
|
|
|
|
+ - Image serving API (extract from ZIP; optional resize/caching)
|
|
|
|
|
+3. **S3-Compatible Storage (Home Server)**
|
|
|
|
|
+ - Stores ZIP objects (large, few)
|
|
|
|
|
+ - Stores per-album `index.json` (small, few)
|
|
|
|
|
+4. **Optional Local Disk Cache (API Server)**
|
|
|
|
|
+ - Cache resized images and/or extracted bytes to reduce repeated ZIP work
|
|
|
|
|
+
|
|
|
|
|
+------
|
|
|
|
|
+
|
|
|
|
|
+## 4. Storage Layout (S3 Keys)
|
|
|
|
|
+
|
|
|
|
|
+Bucket: `photo-archive`
|
|
|
|
|
+
|
|
|
|
|
+For each album (`albumId` is a UUID or content-hash):
|
|
|
|
|
+
|
|
|
|
|
+- Source ZIP (1 object):
|
|
|
|
|
+ - `albums/{albumId}/source.zip`
|
|
|
|
|
+- Album metadata (1 object):
|
|
|
|
|
+ - `albums/{albumId}/index.json`
|
|
|
|
|
+
|
|
|
|
|
+No per-photo thumbnail objects in S3.
|
|
|
|
|
+
|
|
|
|
|
+------
|
|
|
|
|
+
|
|
|
|
|
+## 5. Album Metadata Format (`index.json`)
|
|
|
|
|
+
|
|
|
|
|
+Purpose:
|
|
|
|
|
+
|
|
|
|
|
+- Allow the UI and APIs to:
|
|
|
|
|
+ - list photos in order
|
|
|
|
|
+ - compute masonry layout using aspect ratio
|
|
|
|
|
+ - address a photo by `(albumId, photoIndex)`
|
|
|
|
|
+
|
|
|
|
|
+Example:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "albumId": "a1b2c3...",
|
|
|
|
|
+ "originalFilename": "2021_trip.zip",
|
|
|
|
|
+ "createdAt": "2026-02-13T00:00:00Z",
|
|
|
|
|
+ "photoCount": 20,
|
|
|
|
|
+ "photos": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "i": 0,
|
|
|
|
|
+ "name": "IMG_0001.JPG",
|
|
|
|
|
+ "w": 4032,
|
|
|
|
|
+ "h": 3024,
|
|
|
|
|
+ "ratio": 1.3333
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Notes:
|
|
|
|
|
+
|
|
|
|
|
+- `i` is the stable ordering index (sort by filename or ZIP entry order; pick one and keep it consistent).
|
|
|
|
|
+- Width/height are extracted from image headers (or by decoding if needed).
|
|
|
|
|
+
|
|
|
|
|
+------
|
|
|
|
|
+
|
|
|
|
|
+## 6. Core APIs
|
|
|
|
|
+
|
|
|
|
|
+### 6.1 Create Album Upload Session
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+POST /api/albums
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Request:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "filename": "my_album.zip", "sizeBytes": 123456789 }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Response:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "albumId": "uuid-or-hash",
|
|
|
|
|
+ "upload": {
|
|
|
|
|
+ "method": "PUT",
|
|
|
|
|
+ "url": "presigned-url",
|
|
|
|
|
+ "headers": { }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Behavior:
|
|
|
|
|
+
|
|
|
|
|
+- Server decides `albumId`.
|
|
|
|
|
+- Server returns a presigned PUT URL to upload `albums/{albumId}/source.zip`.
|
|
|
|
|
+
|
|
|
|
|
+### 6.2 Finalize Upload (Trigger Metadata Generation)
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+POST /api/albums/{albumId}/finalize
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Response:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "status": "INDEXING" }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Behavior:
|
|
|
|
|
+
|
|
|
|
|
+- Server verifies ZIP exists in S3.
|
|
|
|
|
+- Server builds `albums/{albumId}/index.json`.
|
|
|
|
|
+- For MVP: do it synchronously (may take seconds). Optionally return early and allow polling.
|
|
|
|
|
+
|
|
|
|
|
+### 6.3 Get Album Metadata
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+GET /api/albums/{albumId}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Response: the `index.json` content.
|
|
|
|
|
+
|
|
|
|
|
+### 6.4 List Albums (Lightweight)
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+GET /api/albums
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Response:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "albums": [
|
|
|
|
|
+ { "albumId": "...", "photoCount": 20, "originalFilename": "..." }
|
|
|
|
|
+ ]
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Implementation:
|
|
|
|
|
+
|
|
|
|
|
+- List `albums/*/index.json` objects (or list `albums/` prefixes and fetch index.json per album; cache results server-side).
|
|
|
|
|
+
|
|
|
|
|
+### 6.5 Random Feed (Page 1)
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+GET /api/feed?limit=80&seed=abc&cursor=...
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Response:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "items": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "albumId": "....",
|
|
|
|
|
+ "i": 7,
|
|
|
|
|
+ "w": 4032,
|
|
|
|
|
+ "h": 3024,
|
|
|
|
|
+ "ratio": 1.3333,
|
|
|
|
|
+ "src": "/api/image/a1b2c3/7?mode=wall&w=480"
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "nextCursor": "..."
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Behavior:
|
|
|
|
|
+
|
|
|
|
|
+- Randomly sample `(albumId, photoIndex)` pairs from existing albums.
|
|
|
|
|
+- Returns enough metadata for masonry and a URL to request the image bytes.
|
|
|
|
|
+- Server can cache album indices in memory for speed.
|
|
|
|
|
+
|
|
|
|
|
+### 6.6 Serve Image Bytes (On-demand)
|
|
|
|
|
+
|
|
|
|
|
+`GET /api/image/{albumId}/{i}?mode=wall&w=480`
|
|
|
|
|
+`GET /api/image/{albumId}/{i}?mode=viewer&max=0`
|
|
|
|
|
+
|
|
|
|
|
+Modes:
|
|
|
|
|
+
|
|
|
|
|
+- `wall`:
|
|
|
|
|
+ - Return a resized image suitable for the wall (e.g., width=480).
|
|
|
|
|
+- `viewer`:
|
|
|
|
|
+ - Return the original (or optionally a large “fit” size).
|
|
|
|
|
+
|
|
|
|
|
+Caching (optional but recommended):
|
|
|
|
|
+
|
|
|
|
|
+- Disk cache key:
|
|
|
|
|
+ - `cache/{albumId}/{i}/wall_{w}.jpg`
|
|
|
|
|
+ - `cache/{albumId}/{i}/viewer_orig.jpg` (or `viewer_{max}.jpg`)
|
|
|
|
|
+
|
|
|
|
|
+------
|
|
|
|
|
+
|
|
|
|
|
+## 7. ZIP Indexing Strategy
|
|
|
|
|
+
|
|
|
|
|
+### 7.1 Minimal Indexing (MVP)
|
|
|
|
|
+
|
|
|
|
|
+When `/finalize` is called:
|
|
|
|
|
+
|
|
|
|
|
+1. Download ZIP to local temp **or** read via Range (optional optimization).
|
|
|
|
|
+2. Enumerate entries; keep only `.jpg/.jpeg/.png`.
|
|
|
|
|
+3. Determine ordering (e.g., filename sort).
|
|
|
|
|
+4. Extract width/height for each entry:
|
|
|
|
|
+ - Prefer header parsing or minimal decode.
|
|
|
|
|
+5. Write `index.json` to S3.
|
|
|
|
|
+
|
|
|
|
|
+ZIP format note:
|
|
|
|
|
+
|
|
|
|
|
+- Many implementations locate the “central directory” at the end of the ZIP to enumerate entries efficiently, which can enable Range-based partial reads. ([Rhardih](https://rhardih.io/tag/zip/?utm_source=chatgpt.com))
|
|
|
|
|
+
|
|
|
|
|
+### 7.2 Range-based ZIP Reading (Optional Optimization)
|
|
|
|
|
+
|
|
|
|
|
+If the home S3 server supports HTTP Range well:
|
|
|
|
|
+
|
|
|
|
|
+- Fetch the last chunk to find the End-of-Central-Directory
|
|
|
|
|
+- Fetch the central directory region
|
|
|
|
|
+- Fetch only the bytes for requested entries
|
|
|
|
|
+
|
|
|
|
|
+This can avoid full ZIP downloads, but is not required for MVP.
|
|
|
|
|
+
|
|
|
|
|
+------
|
|
|
|
|
+
|
|
|
|
|
+## 8. UI Specification (Photo-first, Edge-to-Edge)
|
|
|
|
|
+
|
|
|
|
|
+### 8.1 Page 1 — Random Wall (Immersive Masonry)
|
|
|
|
|
+
|
|
|
|
|
+Principles:
|
|
|
|
|
+
|
|
|
|
|
+- **No padding**. Content is edge-to-edge.
|
|
|
|
|
+- Minimal UI overlays.
|
|
|
|
|
+- Main action is scrolling and tapping photos.
|
|
|
|
|
+
|
|
|
|
|
+Layout:
|
|
|
|
|
+
|
|
|
|
|
+- Masonry/columns layout
|
|
|
|
|
+- Gap: `0` (or extremely small if needed)
|
|
|
|
|
+- Infinite scroll; loads next batch from `/api/feed`
|
|
|
|
|
+
|
|
|
|
|
+Controls:
|
|
|
|
|
+
|
|
|
|
|
+- **Bottom floating control bar** (safe-area aware), auto-hide on scroll:
|
|
|
|
|
+ - Column selector: `1 | 2 | 3 | 4` (single tap)
|
|
|
|
|
+ - Upload: `+`
|
|
|
|
|
+
|
|
|
|
|
+Interaction:
|
|
|
|
|
+
|
|
|
|
|
+- Tap photo -> navigate to Page 2:
|
|
|
|
|
+ - Route: `/album/{albumId}?i={photoIndex}`
|
|
|
|
|
+
|
|
|
|
|
+### 8.2 Page 2 — Album Viewer (Full-screen)
|
|
|
|
|
+
|
|
|
|
|
+Principles:
|
|
|
|
|
+
|
|
|
|
|
+- **One photo per screen**, edge-to-edge.
|
|
|
|
|
+- Default fit mode: **cover** (fills screen; cropping allowed).
|
|
|
|
|
+- Single tap toggles UI overlays.
|
|
|
|
|
+
|
|
|
|
|
+Gestures:
|
|
|
|
|
+
|
|
|
|
|
+- Swipe left/right: previous/next photo
|
|
|
|
|
+- Swipe down: close (return to wall, restore scroll position)
|
|
|
|
|
+- Pinch zoom: optional (if implemented)
|
|
|
|
|
+
|
|
|
|
|
+Overlays (shown only when toggled on):
|
|
|
|
|
+
|
|
|
|
|
+- Top: close/back + `currentIndex / total`
|
|
|
|
|
+- Bottom: minimal progress indicator (optional)
|
|
|
|
|
+
|
|
|
|
|
+------
|
|
|
|
|
+
|
|
|
|
|
+## 9. Performance Strategy (No Small S3 Objects)
|
|
|
|
|
+
|
|
|
|
|
+- Store only:
|
|
|
|
|
+ - **1 ZIP object per album**
|
|
|
|
|
+ - **1 JSON index per album**
|
|
|
|
|
+- Avoid storing thumbnails per photo in S3.
|
|
|
|
|
+- Optional server disk cache for resized images:
|
|
|
|
|
+ - Improves repeated viewing without adding S3 objects.
|
|
|
|
|
+- Server should cache `index.json` in memory to avoid repeated S3 reads.
|
|
|
|
|
+
|
|
|
|
|
+------
|
|
|
|
|
+
|
|
|
|
|
+## 10. Error Handling & Edge Cases
|
|
|
|
|
+
|
|
|
|
|
+- ZIP contains non-images: ignore.
|
|
|
|
|
+- Corrupt image: skip entry; record count accordingly.
|
|
|
|
|
+- Empty album after filtering: mark finalize as failed (return error).
|
|
|
|
|
+- Large ZIP: enforce max size and timeouts.
|
|
|
|
|
+- Security:
|
|
|
|
|
+ - Validate ZIP entry names (prevent path traversal if extracting)
|
|
|
|
|
+ - Enforce allowed extensions and content-type sniffing
|
|
|
|
|
+
|
|
|
|
|
+------
|
|
|
|
|
+
|
|
|
|
|
+## 11. Implementation Milestones (Codex Task Breakdown)
|
|
|
|
|
+
|
|
|
|
|
+1. **Backend**
|
|
|
|
|
+ - S3 client, presigned PUT, upload finalize
|
|
|
|
|
+ - ZIP indexing -> generate `index.json` -> put to S3
|
|
|
|
|
+ - `/feed` sampling using cached indices
|
|
|
|
|
+ - `/image/:albumId/:i` serving + optional resize + optional disk cache
|
|
|
|
|
+2. **Frontend**
|
|
|
|
|
+ - Upload flow (select ZIP -> upload -> finalize -> ready)
|
|
|
|
|
+ - Page 1 masonry wall (edge-to-edge) + infinite scroll + bottom bar
|
|
|
|
|
+ - Page 2 full-screen viewer + swipe navigation + UI toggle + close restore
|
|
|
|
|
+3. **Polish**
|
|
|
|
|
+ - Caching, better error states, basic metrics/logging
|
|
|
|
|
+
|