249
docs/gallery-migration-plan.md
Normal file
249
docs/gallery-migration-plan.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user