# 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