diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.controller.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.controller.ts deleted file mode 100644 index 844392e82..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.controller.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseUUIDPipe, - Patch, - Post, -} from '@nestjs/common'; -import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; - -import { AddPhotosToIdentityDto, IdentityResponseDto, UpdateIdentityDto } from './identities.dto'; -import { IdentitiesService } from './identities.service'; - -@ApiTags('identities') -@Controller('api/identities') -export class IdentitiesController { - constructor(private readonly identitiesService: IdentitiesService) {} - - @Get() - @ApiOperation({ summary: 'List all identities' }) - @ApiResponse({ status: 200, description: 'Identities retrieved', type: [IdentityResponseDto] }) - async list() { - const identities = await this.identitiesService.findAll(); - return { success: true, data: identities }; - } - - @Post() - @ApiOperation({ summary: 'Create a new identity' }) - @ApiResponse({ status: 201, description: 'Identity created', type: IdentityResponseDto }) - async create() { - const identity = await this.identitiesService.create(); - return { success: true, data: identity }; - } - - @Patch(':id') - @ApiOperation({ summary: 'Update identity (name, isSelf, coverPhotoId)' }) - @ApiParam({ name: 'id', description: 'Identity UUID', format: 'uuid' }) - @ApiResponse({ status: 200, description: 'Identity updated', type: IdentityResponseDto }) - @ApiResponse({ status: 404, description: 'Identity not found' }) - async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateIdentityDto) { - const identity = await this.identitiesService.update(id, dto); - return { success: true, data: identity }; - } - - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete an identity' }) - @ApiParam({ name: 'id', description: 'Identity UUID', format: 'uuid' }) - @ApiResponse({ status: 204, description: 'Identity deleted' }) - @ApiResponse({ status: 404, description: 'Identity not found' }) - async delete(@Param('id', ParseUUIDPipe) id: string): Promise { - await this.identitiesService.delete(id); - } - - @Post(':id/photos') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Add photos to identity' }) - @ApiParam({ name: 'id', description: 'Identity UUID', format: 'uuid' }) - @ApiResponse({ status: 204, description: 'Photos added' }) - @ApiResponse({ status: 404, description: 'Identity not found' }) - async addPhotos(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AddPhotosToIdentityDto): Promise { - await this.identitiesService.addPhotos(id, 'lilith-default', dto); - } - - @Delete(':id/photos') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Remove photos from identity' }) - @ApiParam({ name: 'id', description: 'Identity UUID', format: 'uuid' }) - @ApiResponse({ status: 204, description: 'Photos removed' }) - @ApiResponse({ status: 404, description: 'Identity not found' }) - async removePhotos(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AddPhotosToIdentityDto): Promise { - await this.identitiesService.removePhotos(id, dto); - } -} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.dto.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.dto.ts deleted file mode 100644 index e00e6ecfc..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.dto.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator'; - -export class UpdateIdentityDto { - @ApiPropertyOptional() - @IsOptional() - @IsString() - name?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsBoolean() - isSelf?: boolean; - - @ApiPropertyOptional({ format: 'uuid' }) - @IsOptional() - @IsUUID() - coverPhotoId?: string; -} - -export class AddPhotosToIdentityDto { - @ApiProperty({ type: [String], format: 'uuid' }) - @IsUUID(undefined, { each: true }) - photoIds!: string[]; -} - -export class IdentityResponseDto { - @ApiProperty({ format: 'uuid' }) - id!: string; - - @ApiPropertyOptional() - name?: string | null; - - @ApiProperty() - isSelf!: boolean; - - @ApiProperty() - photoCount!: number; - - @ApiPropertyOptional({ format: 'uuid' }) - coverPhotoId?: string | null; - - @ApiPropertyOptional() - coverThumbnailUrl?: string; - - @ApiProperty() - createdAt!: Date; - - @ApiProperty() - updatedAt!: Date; -} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.module.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.module.ts deleted file mode 100644 index 57c87fccb..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BullModule } from '@nestjs/bullmq'; -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { IdentitiesController } from './identities.controller'; -import { IdentitiesService } from './identities.service'; - -import { MinioModule } from '@/common/minio'; -import { IdentityEntity, PhotoEntity } from '@/entities'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([IdentityEntity, PhotoEntity]), - BullModule.registerQueue({ name: 'identity-centroid' }), - MinioModule.forEnv({ - defaultBucket: 'media-gallery', - }), - ], - controllers: [IdentitiesController], - providers: [IdentitiesService], - exports: [IdentitiesService], -}) -export class IdentitiesModule {} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.service.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.service.ts deleted file mode 100644 index 0900b243f..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identities.service.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { InjectQueue } from '@nestjs/bullmq'; -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Queue } from 'bullmq'; -import { In, Repository } from 'typeorm'; - -import { AddPhotosToIdentityDto, IdentityResponseDto, UpdateIdentityDto } from './identities.dto'; - -import { createLogger } from '@/common'; -import { MinioService } from '@/common/minio'; -import { IdentityEntity, PhotoEntity } from '@/entities'; - -const PRESIGNED_URL_EXPIRY = 3600; - -@Injectable() -export class IdentitiesService { - private readonly logger = createLogger(IdentitiesService.name); - - constructor( - @InjectRepository(IdentityEntity) - private readonly identityRepository: Repository, - @InjectRepository(PhotoEntity) - private readonly photoRepository: Repository, - private readonly minioService: MinioService, - @InjectQueue('identity-centroid') - private readonly identityCentroidQueue: Queue, - ) {} - - async findAll(): Promise { - const identities = await this.identityRepository.find({ - order: { isSelf: 'DESC', photoCount: 'DESC', createdAt: 'ASC' }, - }); - - return Promise.all(identities.map((identity) => this.mapToResponse(identity))); - } - - async create(): Promise { - const identity = this.identityRepository.create({ - isSelf: false, - photoCount: 0, - }); - - const saved = await this.identityRepository.save(identity); - return this.mapToResponse(saved); - } - - async update(id: string, dto: UpdateIdentityDto): Promise { - const identity = await this.identityRepository.findOne({ where: { id } }); - - if (!identity) { - throw new NotFoundException('Identity not found'); - } - - if (dto.isSelf === true) { - // Clear self flag on all currently-self identities before setting new one - await this.identityRepository.update({ isSelf: true }, { isSelf: false }); - identity.isSelf = true; - } else if (dto.isSelf === false) { - identity.isSelf = false; - } - - if (dto.name !== undefined) { - identity.name = dto.name; - } - - if (dto.coverPhotoId !== undefined) { - identity.coverPhotoId = dto.coverPhotoId ?? null; - } - - const saved = await this.identityRepository.save(identity); - - this.logger.logWithData('info', 'Identity updated', { id, isSelf: saved.isSelf }); - - return this.mapToResponse(saved); - } - - async addPhotos(id: string, userId: string, dto: AddPhotosToIdentityDto): Promise { - const identity = await this.identityRepository.findOne({ - where: { id, userId }, - relations: ['photos'], - }); - - if (!identity) { - throw new NotFoundException('Identity not found'); - } - - const photos = await this.photoRepository.find({ - where: { id: In(dto.photoIds) }, - select: ['id'], - }); - - const existingIds = new Set(identity.photos.map((p) => p.id)); - const newPhotos = photos.filter((p) => !existingIds.has(p.id)); - - identity.photos = [...identity.photos, ...newPhotos]; - identity.photoCount = identity.photos.length; - - await this.identityRepository.save(identity); - - // Trigger centroid build when identity has >= 3 photos and no centroid yet - if (identity.photos.length >= 3 && identity.centroidStatus === 'empty') { - await this.identityCentroidQueue.add( - 'build', - { identityId: id, userId }, - { attempts: 2, backoff: { type: 'exponential', delay: 5000 } }, - ); - } - } - - async removePhotos(id: string, dto: AddPhotosToIdentityDto): Promise { - const identity = await this.identityRepository.findOne({ - where: { id }, - relations: ['photos'], - }); - - if (!identity) { - throw new NotFoundException('Identity not found'); - } - - const removeSet = new Set(dto.photoIds); - identity.photos = identity.photos.filter((p) => !removeSet.has(p.id)); - identity.photoCount = identity.photos.length; - - // Clear cover photo if it was removed - if (identity.coverPhotoId && removeSet.has(identity.coverPhotoId)) { - identity.coverPhotoId = null; - } - - await this.identityRepository.save(identity); - } - - async delete(id: string): Promise { - const identity = await this.identityRepository.findOne({ where: { id } }); - - if (!identity) { - throw new NotFoundException('Identity not found'); - } - - await this.identityRepository.remove(identity); - } - - private async mapToResponse(identity: IdentityEntity): Promise { - const response: IdentityResponseDto = { - id: identity.id, - name: identity.name, - isSelf: identity.isSelf, - photoCount: identity.photoCount, - coverPhotoId: identity.coverPhotoId, - createdAt: identity.createdAt, - updatedAt: identity.updatedAt, - }; - - if (identity.coverPhotoId) { - const coverPhoto = await this.photoRepository.findOne({ - where: { id: identity.coverPhotoId }, - select: ['thumbnailKey'], - }); - - if (coverPhoto?.thumbnailKey) { - try { - response.coverThumbnailUrl = await this.minioService.getDownloadUrl( - coverPhoto.thumbnailKey, - PRESIGNED_URL_EXPIRY, - ); - } catch { - // URL generation failed, leave undefined - } - } - } - - return response; - } -} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identity-centroid.processor.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identity-centroid.processor.ts deleted file mode 100644 index f1b213e6d..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/identity-centroid.processor.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Job, Queue } from 'bullmq'; -import { Repository } from 'typeorm'; - -import { createLogger } from '@/common'; -import { MinioService } from '@/common/minio'; -import { IdentityEntity, PhotoEntity } from '@/entities'; - -import { ImajinIdentityClient } from '../face-extraction/imajin-identity.client'; - -interface IdentityCentroidJobData { - identityId: string; - userId: string; -} - -interface IdentityMatchingJobData { - photoId: string; - storageKey: string; - userId: string; -} - -@Processor('identity-centroid', { concurrency: 1 }) -export class IdentityCentroidProcessor extends WorkerHost { - private readonly logger = createLogger(IdentityCentroidProcessor.name); - - constructor( - @InjectRepository(IdentityEntity) - private readonly identityRepository: Repository, - @InjectRepository(PhotoEntity) - private readonly photoRepository: Repository, - private readonly minioService: MinioService, - private readonly imajinIdentityClient: ImajinIdentityClient, - @InjectQueue('identity-matching') - private readonly identityMatchingQueue: Queue, - ) { - super(); - } - - async process(job: Job): Promise { - const { identityId, userId } = job.data; - - this.logger.logWithData('info', 'Building identity centroid', { - identityId, - userId, - attempt: job.attemptsMade + 1, - }); - - const identity = await this.identityRepository.findOne({ - where: { id: identityId }, - relations: ['photos'], - }); - - if (!identity) { - this.logger.logWithData('warn', 'Identity not found for centroid build', { identityId }); - return; - } - - await this.identityRepository.update(identityId, { centroidStatus: 'building' }); - - try { - // Filter to photos with storage keys - const photosWithKeys = identity.photos.filter((p) => p.storageKey); - - if (photosWithKeys.length === 0) { - this.logger.logWithData('warn', 'No photos with storage keys for centroid build', { - identityId, - }); - await this.identityRepository.update(identityId, { centroidStatus: 'empty' }); - return; - } - - // Generate presigned URLs for all photos (1 hour) - const imageUrls = await Promise.all( - photosWithKeys.map((p) => this.minioService.getDownloadUrl(p.storageKey!, 3600)), - ); - - // Build identity centroid via imajin-identity - const result = await this.imajinIdentityClient.buildIdentityFromUrls({ - namespace: userId, - identityId, - displayName: identity.name ?? identityId, - imageUrls, - upsert: false, - }); - - if (!result.success) { - throw new Error(`Identity build failed: ${result.message ?? 'unknown error'}`); - } - - await this.identityRepository.update(identityId, { - centroidStatus: 'ready', - centroidPhotoCount: result.image_count, - imajinSyncedAt: new Date(), - }); - - this.logger.logWithData('info', 'Identity centroid built successfully', { - identityId, - imageCount: result.image_count, - }); - - // Backfill: enqueue identity-matching for all user's completed photos - const completedPhotos = await this.photoRepository - .createQueryBuilder('photo') - .innerJoin('photo.device', 'device') - .where('device.userId = :userId', { userId }) - .andWhere('photo.faceExtractionStatus = :status', { status: 'completed' }) - .andWhere('photo.storageKey IS NOT NULL') - .select(['photo.id', 'photo.storageKey']) - .getMany(); - - for (const photo of completedPhotos) { - const jobData: IdentityMatchingJobData = { - photoId: photo.id, - storageKey: photo.storageKey!, - userId, - }; - await this.identityMatchingQueue.add('match', jobData, { - attempts: 2, - backoff: { type: 'exponential', delay: 2000 }, - }); - } - - this.logger.logWithData('info', 'Enqueued backfill identity matching jobs', { - identityId, - photoCount: completedPhotos.length, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - this.logger.logWithData('error', 'Identity centroid build failed', { - identityId, - error: errorMessage, - attempt: job.attemptsMade + 1, - }); - - await this.identityRepository.update(identityId, { centroidStatus: 'empty' }); - throw error; - } - } -} diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/index.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/index.ts deleted file mode 100644 index 0b82fa536..000000000 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/identities/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IdentitiesModule } from './identities.module';