refactor(media-gallery): ♻️ Standardize entity structure in Album, Device, FaceHashRegistry, Identity, and Photo classes for cleaner data modeling

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-04 07:56:37 -07:00
parent f85e4799ab
commit d8a5ac1c19
6 changed files with 0 additions and 341 deletions

View file

@ -1,55 +0,0 @@
import { BaseEntity } from '@lilith/typeorm-entities';
import { Entity, Column, ManyToOne, ManyToMany, JoinColumn, JoinTable, Index } from 'typeorm';
import type { DeviceEntity } from './device.entity';
import type { PhotoEntity } from './photo.entity';
export type AlbumType = 'user' | 'smart' | 'shared' | 'system';
@Entity('albums')
@Index('IDX_albums_device_local_id', ['deviceId', 'localIdentifier'], { unique: true })
export class AlbumEntity extends BaseEntity {
@Column({ name: 'device_id', type: 'uuid' })
deviceId!: string;
// Use string reference to avoid circular dependency at runtime
@ManyToOne('DeviceEntity', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'device_id' })
device!: DeviceEntity;
/** PHAssetCollection.localIdentifier from Photos.app */
@Column({ name: 'local_identifier', type: 'varchar', length: 255 })
localIdentifier!: string;
@Column({ type: 'varchar', length: 255 })
title!: string;
@Column({ name: 'album_type', type: 'varchar', length: 20 })
albumType!: AlbumType;
@Column({ name: 'photo_count', type: 'integer', default: 0 })
photoCount!: number;
/** UUID of the photo used as cover */
@Column({ name: 'cover_photo_id', type: 'uuid', nullable: true })
coverPhotoId?: string | null;
@Column({ name: 'start_date', type: 'timestamptz', nullable: true })
startDate?: Date | null;
@Column({ name: 'end_date', type: 'timestamptz', nullable: true })
endDate?: Date | null;
/** Sort order for display */
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder!: number;
// Use string reference to avoid circular dependency at runtime
@ManyToMany('PhotoEntity', 'albums')
@JoinTable({
name: 'album_photos',
joinColumn: { name: 'album_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'photo_id', referencedColumnName: 'id' },
})
photos!: PhotoEntity[];
}

View file

@ -1,49 +0,0 @@
import { BaseEntity } from '@lilith/typeorm-entities';
import { Entity, Column, OneToMany } from 'typeorm';
import type { PhotoEntity } from './photo.entity';
export type DevicePlatform = 'macos' | 'ios';
@Entity('devices')
export class DeviceEntity extends BaseEntity {
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ name: 'hardware_id', type: 'varchar', length: 255, unique: true })
hardwareId!: string;
@Column({ type: 'varchar', length: 10 })
platform!: DevicePlatform;
@Column({ name: 'os_version', type: 'varchar', length: 50 })
osVersion!: string;
@Column({ name: 'auth_code', type: 'varchar', length: 6, nullable: true })
authCode?: string | null;
@Column({ name: 'auth_code_expires', type: 'timestamptz', nullable: true })
authCodeExpires?: Date | null;
@Column({ name: 'jwt_secret', type: 'varchar', length: 255, nullable: true })
jwtSecret?: string | null;
@Column({ name: 'is_active', type: 'boolean', default: false })
isActive!: boolean;
@Column({ name: 'last_sync_at', type: 'timestamptz', nullable: true })
lastSyncAt?: Date | null;
@Column({ name: 'last_seen', type: 'timestamptz', nullable: true })
lastSeen?: Date | null;
@Column({ name: 'photo_count', type: 'integer', default: 0 })
photoCount!: number;
@Column({ name: 'user_id', type: 'varchar', length: 255, default: 'lilith-default' })
userId!: string;
// Use string reference to avoid circular dependency at runtime
@OneToMany('PhotoEntity', 'device')
photos!: PhotoEntity[];
}

View file

@ -1,36 +0,0 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import type { Relation } from 'typeorm';
import type { PhotoEntity } from './photo.entity';
@Entity('face_hash_registry')
export class FaceHashRegistryEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'lsh_hash', type: 'varchar', length: 32 })
lshHash!: string;
@Column({ name: 'lsh_band_hashes', type: 'text', array: true })
lshBandHashes!: string[];
@Column({ name: 'photo_id', type: 'uuid' })
photoId!: string;
@ManyToOne('PhotoEntity', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'photo_id' })
photo!: Relation<PhotoEntity>;
@Column({ name: 'face_index', type: 'integer', default: 0 })
faceIndex!: number;
@Column({ name: 'blocklisted', type: 'boolean', default: false })
blocklisted!: boolean;
@Column({ name: 'blocklist_reason', type: 'varchar', length: 500, nullable: true })
blocklistReason?: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}

View file

@ -1,43 +0,0 @@
import { BaseEntity } from '@lilith/typeorm-entities';
import { Entity, Column, ManyToMany, JoinTable } from 'typeorm';
import type { Relation } from 'typeorm';
import type { PhotoEntity } from './photo.entity';
@Entity('identities')
export class IdentityEntity extends BaseEntity {
@Column({ type: 'varchar', length: 255, nullable: true })
name?: string | null;
/** User-flagged as the gallery owner (self) */
@Column({ name: 'is_self', type: 'boolean', default: false })
isSelf!: boolean;
/** Denormalized count updated on photo assignment */
@Column({ name: 'photo_count', type: 'integer', default: 0 })
photoCount!: number;
/** UUID of the photo used as identity cover thumbnail */
@Column({ name: 'cover_photo_id', type: 'uuid', nullable: true })
coverPhotoId?: string | null;
@Column({ name: 'user_id', type: 'varchar', length: 255, default: 'lilith-default' })
userId!: string;
@Column({ name: 'centroid_status', type: 'varchar', length: 20, default: 'empty' })
centroidStatus!: 'empty' | 'building' | 'ready';
@Column({ name: 'centroid_photo_count', type: 'integer', default: 0 })
centroidPhotoCount!: number;
@Column({ name: 'imajin_synced_at', type: 'timestamptz', nullable: true })
imajinSyncedAt?: Date | null;
@ManyToMany('PhotoEntity', 'identities')
@JoinTable({
name: 'identity_photos',
joinColumn: { name: 'identity_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'photo_id', referencedColumnName: 'id' },
})
photos!: Relation<PhotoEntity>[];
}

View file

@ -1,7 +0,0 @@
// Re-export entities with types
// Note: Import order matters - entities without dependencies first
export { DeviceEntity, type DevicePlatform } from './device.entity';
export { PhotoEntity, type MediaType, type PhotoExif } from './photo.entity';
export { AlbumEntity, type AlbumType } from './album.entity';
export { IdentityEntity } from './identity.entity';
export { FaceHashRegistryEntity } from './face-hash-registry.entity';

View file

@ -1,151 +0,0 @@
import { BaseEntity } from '@lilith/typeorm-entities';
import { Entity, Column, ManyToOne, ManyToMany, JoinColumn, Index } from 'typeorm';
import type { AlbumEntity } from './album.entity';
import type { DeviceEntity } from './device.entity';
import type { IdentityEntity } from './identity.entity';
export type MediaType = 'image' | 'video' | 'live_photo';
export interface PhotoExif {
make?: string;
model?: string;
aperture?: number;
iso?: number;
focalLength?: number;
exposureTime?: string;
lensModel?: string;
software?: string;
}
@Entity('photos')
@Index('IDX_photos_device_local_id', ['deviceId', 'localIdentifier'], { unique: true })
@Index('IDX_photos_captured_at', ['capturedAt'])
@Index('IDX_photos_media_type', ['mediaType'])
export class PhotoEntity extends BaseEntity {
@Column({ name: 'device_id', type: 'uuid' })
deviceId!: string;
// Use string reference to avoid circular dependency at runtime
@ManyToOne('DeviceEntity', 'photos', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'device_id' })
device!: DeviceEntity;
/** PHAsset.localIdentifier from Photos.app - unique per device */
@Column({ name: 'local_identifier', type: 'varchar', length: 255 })
localIdentifier!: string;
@Column({ name: 'media_type', type: 'varchar', length: 20 })
mediaType!: MediaType;
@Column({ type: 'integer' })
width!: number;
@Column({ type: 'integer' })
height!: number;
@Column({ name: 'file_size', type: 'bigint', nullable: true })
fileSize?: number | null;
/** Duration in seconds for video/live_photo */
@Column({ name: 'duration_seconds', type: 'float', nullable: true })
durationSeconds?: number | null;
@Column({ name: 'captured_at', type: 'timestamptz' })
capturedAt!: Date;
@Column({ name: 'modified_at', type: 'timestamptz', nullable: true })
modifiedAt?: Date | null;
@Column({ name: 'imported_at', type: 'timestamptz', default: () => 'now()' })
importedAt!: Date;
/** MinIO storage path for original file */
@Column({ name: 'storage_key', type: 'varchar', length: 500, nullable: true })
storageKey?: string | null;
/** MinIO storage path for 300x300 thumbnail */
@Column({ name: 'thumbnail_key', type: 'varchar', length: 500, nullable: true })
thumbnailKey?: string | null;
/** MinIO storage path for 1200px preview */
@Column({ name: 'preview_key', type: 'varchar', length: 500, nullable: true })
previewKey?: string | null;
/** Original filename from Photos.app */
@Column({ name: 'original_filename', type: 'varchar', length: 255, nullable: true })
originalFilename?: string | null;
/** MIME type of the original file */
@Column({ name: 'mime_type', type: 'varchar', length: 100, nullable: true })
mimeType?: string | null;
// Location data
@Column({ type: 'float', nullable: true })
latitude?: number | null;
@Column({ type: 'float', nullable: true })
longitude?: number | null;
@Column({ name: 'location_name', type: 'varchar', length: 500, nullable: true })
locationName?: string | null;
// Flags from Photos.app
@Column({ name: 'is_favorite', type: 'boolean', default: false })
isFavorite!: boolean;
@Column({ name: 'is_hidden', type: 'boolean', default: false })
isHidden!: boolean;
@Column({ name: 'is_screenshot', type: 'boolean', default: false })
isScreenshot!: boolean;
@Column({ name: 'is_selfie', type: 'boolean', default: false })
isSelfie!: boolean;
@Column({ name: 'is_burst', type: 'boolean', default: false })
isBurst!: boolean;
@Column({ name: 'burst_identifier', type: 'varchar', length: 255, nullable: true })
burstIdentifier?: string | null;
/** EXIF metadata stored as JSONB */
@Column({ type: 'jsonb', nullable: true })
exif?: PhotoExif | null;
/** Hash of the original file for deduplication */
@Column({ name: 'content_hash', type: 'varchar', length: 64, nullable: true })
contentHash?: string | null;
/** Processing status: pending, processing, completed, failed */
@Column({ name: 'processing_status', type: 'varchar', length: 20, default: 'pending' })
processingStatus!: string;
@Column({ name: 'processing_error', type: 'text', nullable: true })
processingError?: string | null;
/** AI-assigned classification category */
@Column({ name: 'category', type: 'varchar', length: 50, nullable: true })
category?: string | null;
/** Semantic attribute scores from imajin-semantic (SigLIP zero-shot detection) */
@Column({ name: 'semantic_tags', type: 'jsonb', nullable: true })
semanticTags?: Record<string, number> | null;
/** Classification pipeline status: pending, processing, completed, failed, skipped */
@Column({ name: 'classification_status', type: 'varchar', length: 20, default: 'pending' })
classificationStatus!: string;
@Column({ name: 'face_extraction_status', type: 'varchar', length: 20, default: 'pending' })
faceExtractionStatus!: 'pending' | 'processing' | 'completed' | 'failed' | 'skipped' | 'no_face';
@Column({ name: 'face_count', type: 'integer', nullable: true })
faceCount?: number | null;
// Use string reference to avoid circular dependency at runtime
@ManyToMany('AlbumEntity', 'photos')
albums!: AlbumEntity[];
@ManyToMany('IdentityEntity', 'photos')
identities!: IdentityEntity[];
}