# Replace Media Page with Gallery + Migrate Immich Data

## Context
Eliminating Immich dependency. The `/media` page gets replaced with a new `/gallery` page that mirrors the paid content gallery design (justified layout, daily grouping, lightbox, slideshow, timeline scrubber) but without creator groups — opens straight to the timeline. All 99,108 Immich assets (86,647 active + 12,461 deleted/recycled) are migrated into the main app database. Eva Longoria's 80,764 face detections are also migrated. No files are moved — only metadata is copied.

---

## Phase 1: Database Schema

**File**: `/opt/media-downloader/modules/db_bootstrap.py` — add `CREATE TABLE IF NOT EXISTS` statements

### Table: `gallery_assets`
```sql
CREATE TABLE gallery_assets (
    id                SERIAL PRIMARY KEY,
    immich_id         TEXT UNIQUE,
    local_path        TEXT NOT NULL UNIQUE,
    original_filename TEXT,
    file_type         TEXT NOT NULL,          -- 'image' or 'video'
    width             INTEGER,
    height            INTEGER,
    file_size         BIGINT,
    duration          REAL,                   -- seconds
    file_hash         TEXT,
    file_created_at   TIMESTAMP,              -- the "media date"
    is_favorite       BOOLEAN DEFAULT FALSE,
    deleted_at        TIMESTAMP DEFAULT NULL,  -- soft delete = recycle bin
    visibility        TEXT DEFAULT 'timeline',
    created_at        TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at        TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes: file_type, file_created_at DESC, file_hash, deleted_at
```

### Table: `gallery_persons`
```sql
CREATE TABLE gallery_persons (
    id             SERIAL PRIMARY KEY,
    immich_id      TEXT UNIQUE,
    name           TEXT NOT NULL,
    is_favorite    BOOLEAN DEFAULT FALSE,
    thumbnail_path TEXT,
    created_at     TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```

### Table: `gallery_face_detections`
```sql
CREATE TABLE gallery_face_detections (
    id               SERIAL PRIMARY KEY,
    immich_id        TEXT UNIQUE,
    asset_id         INTEGER NOT NULL REFERENCES gallery_assets(id) ON DELETE CASCADE,
    person_id        INTEGER REFERENCES gallery_persons(id) ON DELETE SET NULL,
    bounding_box_x1  INTEGER,
    bounding_box_y1  INTEGER,
    bounding_box_x2  INTEGER,
    bounding_box_y2  INTEGER,
    image_width      INTEGER,
    image_height     INTEGER,
    source_type      TEXT,
    created_at       TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes: asset_id, person_id
```

---

## Phase 2: Migration Script

**File to create**: `/opt/media-downloader/scripts/migrate_immich_to_gallery.py`

Connects to Immich PostgreSQL (`immich_postgres` container, db `immich`, user `postgres`) and main app PostgreSQL.

### Stage 1: Active assets (86,647)
- `SELECT id, type, "originalPath", "fileCreatedAt", "isFavorite", checksum, width, height, duration, visibility FROM assets WHERE "deletedAt" IS NULL`
- Path: replace `/mnt/media/` with `/opt/immich/`
- Type: `'IMAGE'` → `'image'`, `'VIDEO'` → `'video'`
- Duration: parse `'HH:MM:SS.mmm'` string → float seconds
- Checksum: bytea → hex string
- File size: JOIN with `exif."fileSizeInByte"` where available
- Batch INSERT 5,000 at a time, `ON CONFLICT (immich_id) DO UPDATE` for idempotency

### Stage 2: Deleted/recycled assets (12,461)
- Same query but `WHERE "deletedAt" IS NOT NULL`
- Set `deleted_at` to Immich's `"deletedAt"` value
- These form the recycle bin

### Stage 3: Eva Longoria person record
- Find Eva's person UUID: `SELECT id, name, "isFavorite", "thumbnailPath" FROM person WHERE name = 'Eva Longoria'`
- INSERT into `gallery_persons`

### Stage 4: Eva Longoria face detections (80,764)
- `SELECT af.* FROM asset_faces af WHERE af."personId" = '{eva_uuid}' AND af."deletedAt" IS NULL`
- Map Immich asset UUIDs → `gallery_assets.id` via lookup dict
- Batch INSERT 10,000 at a time

### Features
- Idempotent (safe to re-run)
- Progress reporting
- Verification counts at end

---

## Phase 3: Backend API

**File to create**: `/opt/media-downloader/web/backend/routers/gallery.py`

Prefix: `/api/gallery`

### `GET /api/gallery/media`
Mirrors paid content gallery endpoint. Params: `content_type`, `person_id`, `date_from`, `date_to`, `search`, `shuffle`, `shuffle_seed`, `limit`, `offset`. Queries `gallery_assets WHERE deleted_at IS NULL AND visibility = 'timeline'`. Returns items + total + pagination.

### `GET /api/gallery/date-range`
Returns `[{year, month, count}]` for TimelineScrubber. Same pattern as paid content.

### `GET /api/gallery/thumbnail/{asset_id}`
3-tier cache: file cache at `/opt/media-downloader/cache/thumbnails/gallery/{size}/`, generate on-demand using shared `generate_image_thumbnail()` / `generate_video_thumbnail()` from `web/backend/core/utils.py`. Looks up `gallery_assets.local_path`.

### `GET /api/gallery/serve`
Serves full file with byte-range support. Validates path under `/opt/immich/`.

### `GET /api/gallery/persons`
List named persons with face counts.

### `GET /api/gallery/stats`
Total/image/video counts.

**Also modify**:
- Router registration in `web/backend/api.py`
- Add `/opt/immich` to allowed paths in `web/backend/core/utils.py`

---

## Phase 4: Frontend

### 4a: API types + methods
**File**: `/opt/media-downloader/web/frontend/src/lib/api.ts`

New `GalleryAssetItem` interface (simpler than `GalleryMediaItem` — no creator/post fields):
```typescript
export interface GalleryAssetItem {
  id: number; local_path: string | null; name: string;
  file_type: string; width: number | null; height: number | null;
  duration: number | null; file_size: number | null;
  file_hash: string | null; media_date: string | null; is_favorite: boolean;
}
```

New `api.gallery` namespace: `getMedia()`, `getDateRange()`, `getPersons()`, `getStats()`

### 4b: GalleryLightbox component
**File to create**: `/opt/media-downloader/web/frontend/src/components/GalleryLightbox.tsx`

Based on `BundleLightbox.tsx` (1505 lines) with paid-content features stripped and metadata panel from `EnhancedLightbox.tsx` (1051 lines).

**REMOVE from BundleLightbox** (paid-content-specific):
- Watch Later queries/mutations (lines 134-167) and menu item (lines 931-937)
- Bundle sidebar — both desktop (lines 754-815) and mobile (lines 1376-1428)
- Creator info bottom bar (lines 1443-1501): avatar, username, post content, "View Post" button
- Delete functionality: `onDelete` prop, delete button, keyboard shortcut
- Private gallery Lock icon overlays
- `PaidContentPost` prop — no longer needed
- All `api.paidContent.*` calls
- `User`, `Lock`, `Trash2` icon imports

**KEEP from BundleLightbox** (core features):
- Image display with zoom/pan (pinch, mouse wheel, drag)
- Video player with HLS.js + direct file fallback
- Navigation (prev/next, keyboard)
- Slideshow mode with interval control (3s/5s/8s/10s)
- Shuffle toggle (parent-managed)
- Favorite toggle (heart icon)
- Swipe gestures for mobile
- Picture-in-Picture for video
- Download button, copy path
- Position indicator with total count
- Mobile/landscape responsiveness, safe area support

**REPLACE metadata panel** with EnhancedLightbox-style (`EnhancedLightbox.tsx` lines 784-987):
- Filename
- Resolution with label (4K/1080p/720p via `formatResolution()`)
- File size
- Date (file_created_at)
- Duration (for videos)
- File path
- Face recognition section (matched person name + confidence %, green/red coloring)
- Embedded file metadata (title, artist, description — fetched via `/api/media/embedded-metadata`)
- Thumbnail strip at bottom for quick navigation (EnhancedLightbox lines 694-769)

**New props** (simplified):
```typescript
interface GalleryLightboxProps {
  items: GalleryAssetItem[]
  currentIndex: number
  onClose: () => void
  onNavigate: (index: number) => void
  onToggleFavorite?: () => void
  initialSlideshow?: boolean
  initialInterval?: number
  isShuffled?: boolean
  onShuffleChange?: (enabled: boolean) => void
  totalCount?: number
  hasMore?: boolean
  onLoadMore?: () => void
}
```

**URL changes**:
- Serve: `/api/gallery/serve?path=...`
- Thumbnail: `/api/gallery/thumbnail/{id}?size=medium`
- Embedded metadata: `/api/media/embedded-metadata?file_path=...` (reuse existing endpoint)

### 4c: Gallery page component
**File to create**: `/opt/media-downloader/web/frontend/src/pages/Gallery.tsx`

Adapted from `GalleryTimeline.tsx` without creator groups:
- No `groupId`/`onBack` — renders directly as the page
- Title: "Gallery" with stats subtitle
- Uses `api.gallery.getMedia()` / `api.gallery.getDateRange()`
- Thumbnail URL: `/api/gallery/thumbnail/{id}?size=large`
- Same justified layout, daily grouping, content type toggle, slideshow, infinite scroll
- Imports `TimelineScrubber` from `../components/paid-content/TimelineScrubber`
- Imports `GalleryLightbox` from `../components/GalleryLightbox` (new standalone lightbox)
- Copy utility functions: `buildJustifiedRows`, `formatDayLabel`, `formatDuration`, `getAspectRatio`, `JustifiedSection`

### 4d: Routing + nav
**File**: `/opt/media-downloader/web/frontend/src/App.tsx`
- Nav: `{ path: '/media', label: 'Media' }` → `{ path: '/gallery', label: 'Gallery' }`
- Route: `/media` → `/gallery` (add redirect from `/media` to `/gallery`)
- Lazy import new Gallery page

### 4e: Update references
- `breadcrumbConfig.ts`: `/media` → `/gallery`, label "Gallery"
- `Downloads.tsx`: "Media Library" labels
- `Review.tsx`: "Moving Files to Media Library" text
- `Features.tsx`: `/media` path
- `Configuration.tsx`: media section path

---

## Verification
1. Run migration script — confirm 99,108 assets (86,647 active + 12,461 deleted), 1 person, ~80K faces
2. API: `api-call.sh GET /api/gallery/media?limit=5` returns items
3. API: `api-call.sh GET /api/gallery/date-range` returns year/month distribution
4. Frontend: `/gallery` shows justified timeline with thumbnails
5. Content type toggle, infinite scroll, slideshow, lightbox all work
6. Timeline scrubber navigates correctly
7. `/media` redirects to `/gallery`
8. Paid content gallery unchanged
