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

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

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
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
);
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):

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):

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)

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