Files
media-downloader/docs/gallery-migration-plan.md
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

250 lines
9.8 KiB
Markdown

# 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