9.8 KiB
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
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
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
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 UPDATEfor idempotency
Stage 2: Deleted/recycled assets (12,461)
- Same query but
WHERE "deletedAt" IS NOT NULL - Set
deleted_atto 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.idvia 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/immichto allowed paths inweb/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):
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:
onDeleteprop, delete button, keyboard shortcut - Private gallery Lock icon overlays
PaidContentPostprop — no longer needed- All
api.paidContent.*calls User,Lock,Trash2icon 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):
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
TimelineScrubberfrom../components/paid-content/TimelineScrubber - Imports
GalleryLightboxfrom../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/mediato/gallery) - Lazy import new Gallery page
4e: Update references
breadcrumbConfig.ts:/media→/gallery, label "Gallery"Downloads.tsx: "Media Library" labelsReview.tsx: "Moving Files to Media Library" textFeatures.tsx:/mediapathConfiguration.tsx: media section path
Verification
- Run migration script — confirm 99,108 assets (86,647 active + 12,461 deleted), 1 person, ~80K faces
- API:
api-call.sh GET /api/gallery/media?limit=5returns items - API:
api-call.sh GET /api/gallery/date-rangereturns year/month distribution - Frontend:
/galleryshows justified timeline with thumbnails - Content type toggle, infinite scroll, slideshow, lightbox all work
- Timeline scrubber navigates correctly
/mediaredirects to/gallery- Paid content gallery unchanged