2024-10-02 10:54:35 -04:00
|
|
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
2024-03-20 22:15:09 -05:00
|
|
|
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
|
2024-03-20 21:20:38 +01:00
|
|
|
import { StorageCore } from 'src/cores/storage.core';
|
2024-10-31 13:42:58 -04:00
|
|
|
import { OnJob } from 'src/decorators';
|
2024-03-20 23:53:07 +01:00
|
|
|
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
|
|
|
|
import { AuthDto } from 'src/dtos/auth.dto';
|
2023-07-18 20:09:43 +02:00
|
|
|
import {
|
2023-12-05 16:43:15 +01:00
|
|
|
AssetFaceResponseDto,
|
|
|
|
|
AssetFaceUpdateDto,
|
|
|
|
|
FaceDto,
|
2023-07-18 20:09:43 +02:00
|
|
|
MergePersonDto,
|
|
|
|
|
PeopleResponseDto,
|
2023-07-23 05:00:43 +02:00
|
|
|
PeopleUpdateDto,
|
2024-03-07 15:34:57 -05:00
|
|
|
PersonCreateDto,
|
2023-07-18 20:09:43 +02:00
|
|
|
PersonResponseDto,
|
|
|
|
|
PersonSearchDto,
|
2023-10-24 17:53:49 +02:00
|
|
|
PersonStatisticsResponseDto,
|
2023-07-18 20:09:43 +02:00
|
|
|
PersonUpdateDto,
|
2023-12-05 16:43:15 +01:00
|
|
|
mapFaces,
|
2023-09-04 15:45:59 -04:00
|
|
|
mapPerson,
|
2024-03-20 23:53:07 +01:00
|
|
|
} from 'src/dtos/person.dto';
|
2024-06-16 15:25:27 -04:00
|
|
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
2024-08-15 06:57:01 -04:00
|
|
|
import { AssetEntity } from 'src/entities/asset.entity';
|
2024-10-03 21:58:28 -04:00
|
|
|
import { FaceSearchEntity } from 'src/entities/face-search.entity';
|
2024-03-20 16:02:51 -05:00
|
|
|
import { PersonEntity } from 'src/entities/person.entity';
|
2024-09-27 10:28:42 -04:00
|
|
|
import {
|
|
|
|
|
AssetType,
|
|
|
|
|
CacheControl,
|
|
|
|
|
ImageFormat,
|
|
|
|
|
Permission,
|
|
|
|
|
PersonPathType,
|
|
|
|
|
SourceType,
|
|
|
|
|
SystemMetadataKey,
|
|
|
|
|
} from 'src/enum';
|
2024-10-02 10:54:35 -04:00
|
|
|
import { WithoutProperty } from 'src/interfaces/asset.interface';
|
2024-03-20 22:15:09 -05:00
|
|
|
import {
|
|
|
|
|
JOBS_ASSET_PAGINATION_SIZE,
|
|
|
|
|
JobItem,
|
|
|
|
|
JobName,
|
2024-10-31 13:42:58 -04:00
|
|
|
JobOf,
|
2024-03-20 22:15:09 -05:00
|
|
|
JobStatus,
|
|
|
|
|
QueueName,
|
2024-03-21 12:59:49 +01:00
|
|
|
} from 'src/interfaces/job.interface';
|
2024-10-02 10:54:35 -04:00
|
|
|
import { BoundingBox } from 'src/interfaces/machine-learning.interface';
|
|
|
|
|
import { UpdateFacesData } from 'src/interfaces/person.interface';
|
2024-09-30 17:31:21 -04:00
|
|
|
import { BaseService } from 'src/services/base.service';
|
2025-01-22 17:11:07 -05:00
|
|
|
import { CropOptions, ImageDimensions, InputDimensions } from 'src/types';
|
2024-08-19 20:03:33 -04:00
|
|
|
import { getAssetFiles } from 'src/utils/asset.util';
|
2024-09-27 10:28:42 -04:00
|
|
|
import { ImmichFileResponse } from 'src/utils/file';
|
2024-03-20 22:15:09 -05:00
|
|
|
import { mimeTypes } from 'src/utils/mime-types';
|
2024-09-05 00:23:58 +02:00
|
|
|
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
2024-03-20 22:15:09 -05:00
|
|
|
import { usePagination } from 'src/utils/pagination';
|
2023-05-17 13:07:17 -04:00
|
|
|
|
|
|
|
|
@Injectable()
|
2024-09-30 17:31:21 -04:00
|
|
|
export class PersonService extends BaseService {
|
2023-12-09 23:34:12 -05:00
|
|
|
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
|
2024-12-16 09:47:11 -05:00
|
|
|
const { withHidden = false, closestAssetId, closestPersonId, page, size } = dto;
|
|
|
|
|
let closestFaceAssetId = closestAssetId;
|
2024-07-25 21:59:28 +02:00
|
|
|
const pagination = {
|
|
|
|
|
take: size,
|
|
|
|
|
skip: (page - 1) * size,
|
|
|
|
|
};
|
|
|
|
|
|
2024-12-16 09:47:11 -05:00
|
|
|
if (closestPersonId) {
|
|
|
|
|
const person = await this.personRepository.getById(closestPersonId);
|
|
|
|
|
if (!person?.faceAssetId) {
|
|
|
|
|
throw new NotFoundException('Person not found');
|
|
|
|
|
}
|
|
|
|
|
closestFaceAssetId = person.faceAssetId;
|
|
|
|
|
}
|
2024-09-30 17:31:21 -04:00
|
|
|
const { machineLearning } = await this.getConfig({ withCache: false });
|
2024-10-02 10:54:35 -04:00
|
|
|
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
|
2023-09-18 06:05:35 +02:00
|
|
|
minimumFaceCount: machineLearning.facialRecognition.minFaces,
|
2024-07-25 21:59:28 +02:00
|
|
|
withHidden,
|
2024-12-16 09:47:11 -05:00
|
|
|
closestFaceAssetId,
|
2023-08-16 02:06:49 +02:00
|
|
|
});
|
2024-10-02 10:54:35 -04:00
|
|
|
const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id);
|
2023-07-18 20:09:43 +02:00
|
|
|
|
|
|
|
|
return {
|
2024-07-25 21:59:28 +02:00
|
|
|
people: items.map((person) => mapPerson(person)),
|
|
|
|
|
hasNextPage,
|
2024-01-28 01:54:31 +01:00
|
|
|
total,
|
2024-02-21 23:03:45 +01:00
|
|
|
hidden,
|
2023-07-18 20:09:43 +02:00
|
|
|
};
|
2023-05-17 13:07:17 -04:00
|
|
|
}
|
|
|
|
|
|
2023-12-09 23:34:12 -05:00
|
|
|
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
|
2024-10-10 11:53:53 -04:00
|
|
|
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
|
2023-12-05 16:43:15 +01:00
|
|
|
const person = await this.findOrFail(personId);
|
|
|
|
|
const result: PersonResponseDto[] = [];
|
|
|
|
|
const changeFeaturePhoto: string[] = [];
|
|
|
|
|
for (const data of dto.data) {
|
2024-10-02 10:54:35 -04:00
|
|
|
const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
|
2023-12-05 16:43:15 +01:00
|
|
|
|
|
|
|
|
for (const face of faces) {
|
2024-10-10 11:53:53 -04:00
|
|
|
await this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [face.id] });
|
2023-12-05 16:43:15 +01:00
|
|
|
if (person.faceAssetId === null) {
|
|
|
|
|
changeFeaturePhoto.push(person.id);
|
|
|
|
|
}
|
|
|
|
|
if (face.person && face.person.faceAssetId === face.id) {
|
|
|
|
|
changeFeaturePhoto.push(face.person.id);
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-02 10:54:35 -04:00
|
|
|
await this.personRepository.reassignFace(face.id, personId);
|
2023-12-05 16:43:15 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.push(person);
|
|
|
|
|
}
|
|
|
|
|
if (changeFeaturePhoto.length > 0) {
|
|
|
|
|
// Remove duplicates
|
2024-02-02 04:18:00 +01:00
|
|
|
await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)]);
|
2023-12-05 16:43:15 +01:00
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-09 23:34:12 -05:00
|
|
|
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
|
2024-10-10 11:53:53 -04:00
|
|
|
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
|
|
|
|
|
await this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [dto.id] });
|
2024-10-02 10:54:35 -04:00
|
|
|
const face = await this.personRepository.getFaceById(dto.id);
|
2023-12-05 16:43:15 +01:00
|
|
|
const person = await this.findOrFail(personId);
|
|
|
|
|
|
2024-10-02 10:54:35 -04:00
|
|
|
await this.personRepository.reassignFace(face.id, personId);
|
2023-12-05 16:43:15 +01:00
|
|
|
if (person.faceAssetId === null) {
|
|
|
|
|
await this.createNewFeaturePhoto([person.id]);
|
|
|
|
|
}
|
|
|
|
|
if (face.person && face.person.faceAssetId === face.id) {
|
|
|
|
|
await this.createNewFeaturePhoto([face.person.id]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return await this.findOrFail(personId).then(mapPerson);
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-09 23:34:12 -05:00
|
|
|
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
|
2024-10-10 11:53:53 -04:00
|
|
|
await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.id] });
|
2024-10-02 10:54:35 -04:00
|
|
|
const faces = await this.personRepository.getFaces(dto.id);
|
2023-12-09 23:34:12 -05:00
|
|
|
return faces.map((asset) => mapFaces(asset, auth));
|
2023-12-05 16:43:15 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
|
|
|
|
|
this.logger.debug(
|
|
|
|
|
`Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`,
|
|
|
|
|
);
|
2024-01-01 15:45:42 -05:00
|
|
|
|
|
|
|
|
const jobs: JobItem[] = [];
|
2023-12-05 16:43:15 +01:00
|
|
|
for (const personId of changeFeaturePhoto) {
|
2024-10-02 10:54:35 -04:00
|
|
|
const assetFace = await this.personRepository.getRandomFace(personId);
|
2023-12-05 16:43:15 +01:00
|
|
|
|
2025-01-22 15:17:42 -05:00
|
|
|
if (assetFace) {
|
2024-10-02 10:54:35 -04:00
|
|
|
await this.personRepository.update({ id: personId, faceAssetId: assetFace.id });
|
2024-01-01 15:45:42 -05:00
|
|
|
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } });
|
2023-12-05 16:43:15 +01:00
|
|
|
}
|
|
|
|
|
}
|
2024-01-01 15:45:42 -05:00
|
|
|
|
|
|
|
|
await this.jobRepository.queueAll(jobs);
|
2023-12-05 16:43:15 +01:00
|
|
|
}
|
|
|
|
|
|
2023-12-09 23:34:12 -05:00
|
|
|
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
|
2024-10-10 11:53:53 -04:00
|
|
|
await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] });
|
2023-09-18 23:22:44 +02:00
|
|
|
return this.findOrFail(id).then(mapPerson);
|
2023-05-17 13:07:17 -04:00
|
|
|
}
|
|
|
|
|
|
2023-12-09 23:34:12 -05:00
|
|
|
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
|
2024-10-10 11:53:53 -04:00
|
|
|
await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] });
|
2024-10-02 10:54:35 -04:00
|
|
|
return this.personRepository.getStatistics(id);
|
2023-10-24 17:53:49 +02:00
|
|
|
}
|
|
|
|
|
|
2023-12-12 09:58:25 -05:00
|
|
|
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
2024-10-10 11:53:53 -04:00
|
|
|
await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] });
|
2024-10-02 10:54:35 -04:00
|
|
|
const person = await this.personRepository.getById(id);
|
2023-05-17 13:07:17 -04:00
|
|
|
if (!person || !person.thumbnailPath) {
|
|
|
|
|
throw new NotFoundException();
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-12 09:58:25 -05:00
|
|
|
return new ImmichFileResponse({
|
|
|
|
|
path: person.thumbnailPath,
|
|
|
|
|
contentType: mimeTypes.lookup(person.thumbnailPath),
|
2023-12-18 11:33:46 -05:00
|
|
|
cacheControl: CacheControl.PRIVATE_WITHOUT_CACHE,
|
2023-12-12 09:58:25 -05:00
|
|
|
});
|
2023-05-17 13:07:17 -04:00
|
|
|
}
|
|
|
|
|
|
2024-09-10 09:48:29 -04:00
|
|
|
create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
2024-10-02 10:54:35 -04:00
|
|
|
return this.personRepository.create({
|
2024-09-10 09:48:29 -04:00
|
|
|
ownerId: auth.user.id,
|
|
|
|
|
name: dto.name,
|
|
|
|
|
birthDate: dto.birthDate,
|
|
|
|
|
isHidden: dto.isHidden,
|
|
|
|
|
});
|
2024-03-07 15:34:57 -05:00
|
|
|
}
|
|
|
|
|
|
2023-12-09 23:34:12 -05:00
|
|
|
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
2024-10-10 11:53:53 -04:00
|
|
|
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
|
2023-05-17 13:07:17 -04:00
|
|
|
|
2023-09-26 03:03:22 -04:00
|
|
|
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
|
2024-03-07 15:34:57 -05:00
|
|
|
// TODO: set by faceId directly
|
|
|
|
|
let faceId: string | undefined = undefined;
|
2023-09-26 03:03:22 -04:00
|
|
|
if (assetId) {
|
2024-10-10 11:53:53 -04:00
|
|
|
await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [assetId] });
|
2024-10-02 10:54:35 -04:00
|
|
|
const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]);
|
2023-07-11 16:52:41 -05:00
|
|
|
if (!face) {
|
|
|
|
|
throw new BadRequestException('Invalid assetId for feature face');
|
|
|
|
|
}
|
2023-07-02 17:46:20 -05:00
|
|
|
|
2024-03-07 15:34:57 -05:00
|
|
|
faceId = face.id;
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-02 10:54:35 -04:00
|
|
|
const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
|
2024-03-07 15:34:57 -05:00
|
|
|
|
|
|
|
|
if (assetId) {
|
2023-09-26 03:03:22 -04:00
|
|
|
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
|
2023-07-02 17:46:20 -05:00
|
|
|
}
|
|
|
|
|
|
2023-07-11 16:52:41 -05:00
|
|
|
return mapPerson(person);
|
2023-05-17 13:07:17 -04:00
|
|
|
}
|
|
|
|
|
|
2024-03-07 15:34:57 -05:00
|
|
|
async updateAll(auth: AuthDto, dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
|
2023-07-23 05:00:43 +02:00
|
|
|
const results: BulkIdResponseDto[] = [];
|
|
|
|
|
for (const person of dto.people) {
|
|
|
|
|
try {
|
2023-12-09 23:34:12 -05:00
|
|
|
await this.update(auth, person.id, {
|
2023-07-23 05:00:43 +02:00
|
|
|
isHidden: person.isHidden,
|
|
|
|
|
name: person.name,
|
2023-08-18 22:10:29 +02:00
|
|
|
birthDate: person.birthDate,
|
2023-07-23 05:00:43 +02:00
|
|
|
featureFaceAssetId: person.featureFaceAssetId,
|
2024-08-05 19:13:00 +00:00
|
|
|
});
|
|
|
|
|
results.push({ id: person.id, success: true });
|
2023-07-23 05:00:43 +02:00
|
|
|
} catch (error: Error | any) {
|
|
|
|
|
this.logger.error(`Unable to update ${person.id} : ${error}`, error?.stack);
|
|
|
|
|
results.push({ id: person.id, success: false, error: BulkIdErrorReason.UNKNOWN });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-18 00:08:48 -05:00
|
|
|
private async delete(people: PersonEntity[]) {
|
|
|
|
|
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
|
2024-10-02 10:54:35 -04:00
|
|
|
await this.personRepository.delete(people);
|
2024-01-18 00:08:48 -05:00
|
|
|
this.logger.debug(`Deleted ${people.length} people`);
|
|
|
|
|
}
|
2023-10-03 03:15:11 +02:00
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.PERSON_CLEANUP, queue: QueueName.BACKGROUND_TASK })
|
2024-03-15 14:16:54 +01:00
|
|
|
async handlePersonCleanup(): Promise<JobStatus> {
|
2024-10-02 10:54:35 -04:00
|
|
|
const people = await this.personRepository.getAllWithoutFaces();
|
2024-01-18 00:08:48 -05:00
|
|
|
await this.delete(people);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-05-17 13:07:17 -04:00
|
|
|
}
|
2023-07-11 16:52:41 -05:00
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.QUEUE_FACE_DETECTION, queue: QueueName.FACE_DETECTION })
|
|
|
|
|
async handleQueueDetectFaces({ force }: JobOf<JobName.QUEUE_FACE_DETECTION>): Promise<JobStatus> {
|
2024-09-30 17:31:21 -04:00
|
|
|
const { machineLearning } = await this.getConfig({ withCache: false });
|
2024-05-14 15:31:36 -04:00
|
|
|
if (!isFacialRecognitionEnabled(machineLearning)) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2024-01-18 00:08:48 -05:00
|
|
|
if (force) {
|
2024-10-02 10:54:35 -04:00
|
|
|
await this.personRepository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING });
|
2024-09-05 00:23:58 +02:00
|
|
|
await this.handlePersonCleanup();
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2023-09-27 22:46:46 +02:00
|
|
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
2024-10-03 21:58:28 -04:00
|
|
|
return force === false
|
|
|
|
|
? this.assetRepository.getWithout(pagination, WithoutProperty.FACES)
|
|
|
|
|
: this.assetRepository.getAll(pagination, {
|
2025-01-09 11:15:41 -05:00
|
|
|
orderDirection: 'desc',
|
2024-04-18 21:37:55 -04:00
|
|
|
withFaces: true,
|
|
|
|
|
withArchived: true,
|
|
|
|
|
isVisible: true,
|
2024-10-03 21:58:28 -04:00
|
|
|
});
|
2023-09-27 22:46:46 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for await (const assets of assetPagination) {
|
2024-01-01 15:45:42 -05:00
|
|
|
await this.jobRepository.queueAll(
|
2024-01-18 00:08:48 -05:00
|
|
|
assets.map((asset) => ({ name: JobName.FACE_DETECTION, data: { id: asset.id } })),
|
2024-01-01 15:45:42 -05:00
|
|
|
);
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-03 21:58:28 -04:00
|
|
|
if (force === undefined) {
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.FACE_DETECTION, queue: QueueName.FACE_DETECTION })
|
|
|
|
|
async handleDetectFaces({ id }: JobOf<JobName.FACE_DETECTION>): Promise<JobStatus> {
|
2024-09-30 17:31:21 -04:00
|
|
|
const { machineLearning } = await this.getConfig({ withCache: true });
|
2024-05-14 15:31:36 -04:00
|
|
|
if (!isFacialRecognitionEnabled(machineLearning)) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2025-01-09 11:15:41 -05:00
|
|
|
const relations = { exifInfo: true, faces: { person: false }, files: true };
|
2023-11-05 21:15:12 -05:00
|
|
|
const [asset] = await this.assetRepository.getByIds([id], relations);
|
2024-08-19 20:03:33 -04:00
|
|
|
const { previewFile } = getAssetFiles(asset.files);
|
2024-10-03 21:58:28 -04:00
|
|
|
if (!asset || !previewFile) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-03 21:58:28 -04:00
|
|
|
if (!asset.isVisible) {
|
2024-06-06 23:09:47 -04:00
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces(
|
2024-12-04 15:17:47 -05:00
|
|
|
machineLearning.urls,
|
2024-08-19 20:03:33 -04:00
|
|
|
previewFile.path,
|
2023-09-27 22:46:46 +02:00
|
|
|
machineLearning.facialRecognition,
|
|
|
|
|
);
|
2024-08-19 20:03:33 -04:00
|
|
|
this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`);
|
2023-09-27 22:46:46 +02:00
|
|
|
|
2025-01-21 19:12:28 +01:00
|
|
|
const facesToAdd: (Partial<AssetFaceEntity> & { id: string; assetId: string })[] = [];
|
2024-10-03 21:58:28 -04:00
|
|
|
const embeddings: FaceSearchEntity[] = [];
|
|
|
|
|
const mlFaceIds = new Set<string>();
|
|
|
|
|
for (const face of asset.faces) {
|
|
|
|
|
if (face.sourceType === SourceType.MACHINE_LEARNING) {
|
|
|
|
|
mlFaceIds.add(face.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const heightScale = imageHeight / (asset.faces[0]?.imageHeight || 1);
|
|
|
|
|
const widthScale = imageWidth / (asset.faces[0]?.imageWidth || 1);
|
|
|
|
|
for (const { boundingBox, embedding } of faces) {
|
|
|
|
|
const scaledBox = {
|
|
|
|
|
x1: boundingBox.x1 * widthScale,
|
|
|
|
|
y1: boundingBox.y1 * heightScale,
|
|
|
|
|
x2: boundingBox.x2 * widthScale,
|
|
|
|
|
y2: boundingBox.y2 * heightScale,
|
|
|
|
|
};
|
|
|
|
|
const match = asset.faces.find((face) => this.iou(face, scaledBox) > 0.5);
|
|
|
|
|
|
|
|
|
|
if (match && !mlFaceIds.delete(match.id)) {
|
|
|
|
|
embeddings.push({ faceId: match.id, embedding });
|
2024-10-08 17:37:41 -04:00
|
|
|
} else if (!match) {
|
2024-06-16 15:25:27 -04:00
|
|
|
const faceId = this.cryptoRepository.randomUUID();
|
2024-10-03 21:58:28 -04:00
|
|
|
facesToAdd.push({
|
2024-06-16 15:25:27 -04:00
|
|
|
id: faceId,
|
|
|
|
|
assetId: asset.id,
|
|
|
|
|
imageHeight,
|
|
|
|
|
imageWidth,
|
2024-10-03 21:58:28 -04:00
|
|
|
boundingBoxX1: boundingBox.x1,
|
|
|
|
|
boundingBoxY1: boundingBox.y1,
|
|
|
|
|
boundingBoxX2: boundingBox.x2,
|
|
|
|
|
boundingBoxY2: boundingBox.y2,
|
2024-06-16 15:25:27 -04:00
|
|
|
});
|
2024-10-03 21:58:28 -04:00
|
|
|
embeddings.push({ faceId, embedding });
|
2024-06-16 15:25:27 -04:00
|
|
|
}
|
2024-10-03 21:58:28 -04:00
|
|
|
}
|
|
|
|
|
const faceIdsToRemove = [...mlFaceIds];
|
2024-01-18 00:08:48 -05:00
|
|
|
|
2024-10-03 21:58:28 -04:00
|
|
|
if (facesToAdd.length > 0 || faceIdsToRemove.length > 0 || embeddings.length > 0) {
|
|
|
|
|
await this.personRepository.refreshFaces(facesToAdd, faceIdsToRemove, embeddings);
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-03 21:58:28 -04:00
|
|
|
if (faceIdsToRemove.length > 0) {
|
|
|
|
|
this.logger.log(`Removed ${faceIdsToRemove.length} faces below detection threshold in asset ${id}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (facesToAdd.length > 0) {
|
|
|
|
|
this.logger.log(`Detected ${facesToAdd.length} new faces in asset ${id}`);
|
|
|
|
|
const jobs = facesToAdd.map((face) => ({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id } }) as const);
|
|
|
|
|
await this.jobRepository.queueAll([{ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, ...jobs]);
|
|
|
|
|
} else if (embeddings.length > 0) {
|
|
|
|
|
this.logger.log(`Added ${embeddings.length} face embeddings for asset ${id}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.assetRepository.upsertJobStatus({ assetId: asset.id, facesRecognizedAt: new Date() });
|
2023-11-09 17:55:00 -08:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-03 21:58:28 -04:00
|
|
|
private iou(face: AssetFaceEntity, newBox: BoundingBox): number {
|
|
|
|
|
const x1 = Math.max(face.boundingBoxX1, newBox.x1);
|
|
|
|
|
const y1 = Math.max(face.boundingBoxY1, newBox.y1);
|
|
|
|
|
const x2 = Math.min(face.boundingBoxX2, newBox.x2);
|
|
|
|
|
const y2 = Math.min(face.boundingBoxY2, newBox.y2);
|
|
|
|
|
|
|
|
|
|
const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
|
|
|
|
|
const area1 = (face.boundingBoxX2 - face.boundingBoxX1) * (face.boundingBoxY2 - face.boundingBoxY1);
|
|
|
|
|
const area2 = (newBox.x2 - newBox.x1) * (newBox.y2 - newBox.y1);
|
|
|
|
|
const union = area1 + area2 - intersection;
|
|
|
|
|
|
|
|
|
|
return intersection / union;
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.QUEUE_FACIAL_RECOGNITION, queue: QueueName.FACIAL_RECOGNITION })
|
|
|
|
|
async handleQueueRecognizeFaces({ force, nightly }: JobOf<JobName.QUEUE_FACIAL_RECOGNITION>): Promise<JobStatus> {
|
2024-09-30 17:31:21 -04:00
|
|
|
const { machineLearning } = await this.getConfig({ withCache: false });
|
2024-05-14 15:31:36 -04:00
|
|
|
if (!isFacialRecognitionEnabled(machineLearning)) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.jobRepository.waitForQueueCompletion(QueueName.THUMBNAIL_GENERATION, QueueName.FACE_DETECTION);
|
2024-07-14 18:53:42 -04:00
|
|
|
|
|
|
|
|
if (nightly) {
|
|
|
|
|
const [state, latestFaceDate] = await Promise.all([
|
|
|
|
|
this.systemMetadataRepository.get(SystemMetadataKey.FACIAL_RECOGNITION_STATE),
|
2024-10-02 10:54:35 -04:00
|
|
|
this.personRepository.getLatestFaceDate(),
|
2024-07-14 18:53:42 -04:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (state?.lastRun && latestFaceDate && state.lastRun > latestFaceDate) {
|
|
|
|
|
this.logger.debug('Skipping facial recognition nightly since no face has been added since the last run');
|
|
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-25 01:27:39 -05:00
|
|
|
const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION);
|
2024-01-18 00:08:48 -05:00
|
|
|
|
|
|
|
|
if (force) {
|
2024-10-02 10:54:35 -04:00
|
|
|
await this.personRepository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING });
|
2024-09-05 00:23:58 +02:00
|
|
|
await this.handlePersonCleanup();
|
2024-01-25 01:27:39 -05:00
|
|
|
} else if (waiting) {
|
|
|
|
|
this.logger.debug(
|
|
|
|
|
`Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`,
|
|
|
|
|
);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2024-07-14 18:53:42 -04:00
|
|
|
const lastRun = new Date().toISOString();
|
2025-01-21 19:12:28 +01:00
|
|
|
const facePagination = this.personRepository.getAllFaces(
|
|
|
|
|
force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING },
|
2024-01-18 00:08:48 -05:00
|
|
|
);
|
|
|
|
|
|
2025-01-21 19:12:28 +01:00
|
|
|
let jobs: { name: JobName.FACIAL_RECOGNITION; data: { id: string; deferred: false } }[] = [];
|
|
|
|
|
for await (const face of facePagination) {
|
|
|
|
|
jobs.push({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } });
|
|
|
|
|
|
|
|
|
|
if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) {
|
|
|
|
|
await this.jobRepository.queueAll(jobs);
|
|
|
|
|
jobs = [];
|
|
|
|
|
}
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2025-01-21 19:12:28 +01:00
|
|
|
await this.jobRepository.queueAll(jobs);
|
|
|
|
|
|
2024-07-14 18:53:42 -04:00
|
|
|
await this.systemMetadataRepository.set(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun });
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.FACIAL_RECOGNITION, queue: QueueName.FACIAL_RECOGNITION })
|
|
|
|
|
async handleRecognizeFaces({ id, deferred }: JobOf<JobName.FACIAL_RECOGNITION>): Promise<JobStatus> {
|
2024-09-30 17:31:21 -04:00
|
|
|
const { machineLearning } = await this.getConfig({ withCache: true });
|
2024-05-14 15:31:36 -04:00
|
|
|
if (!isFacialRecognitionEnabled(machineLearning)) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2024-10-02 10:54:35 -04:00
|
|
|
const face = await this.personRepository.getFaceByIdWithAssets(
|
2024-01-18 00:08:48 -05:00
|
|
|
id,
|
2024-06-16 15:25:27 -04:00
|
|
|
{ person: true, asset: true, faceSearch: true },
|
2025-01-22 15:17:42 -05:00
|
|
|
['id', 'personId', 'sourceType'],
|
2024-01-18 00:08:48 -05:00
|
|
|
);
|
2024-01-28 20:17:54 -05:00
|
|
|
if (!face || !face.asset) {
|
2024-01-18 00:08:48 -05:00
|
|
|
this.logger.warn(`Face ${id} not found`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2024-09-05 00:23:58 +02:00
|
|
|
if (face.sourceType !== SourceType.MACHINE_LEARNING) {
|
|
|
|
|
this.logger.warn(`Skipping face ${id} due to source ${face.sourceType}`);
|
|
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-16 15:25:27 -04:00
|
|
|
if (!face.faceSearch?.embedding) {
|
|
|
|
|
this.logger.warn(`Face ${id} does not have an embedding`);
|
|
|
|
|
return JobStatus.FAILED;
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-18 00:08:48 -05:00
|
|
|
if (face.personId) {
|
|
|
|
|
this.logger.debug(`Face ${id} already has a person assigned`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2024-10-02 10:54:35 -04:00
|
|
|
const matches = await this.searchRepository.searchFaces({
|
2024-01-18 00:08:48 -05:00
|
|
|
userIds: [face.asset.ownerId],
|
2024-06-16 15:25:27 -04:00
|
|
|
embedding: face.faceSearch.embedding,
|
2024-01-18 00:08:48 -05:00
|
|
|
maxDistance: machineLearning.facialRecognition.maxDistance,
|
|
|
|
|
numResults: machineLearning.facialRecognition.minFaces,
|
|
|
|
|
});
|
|
|
|
|
|
2024-02-07 10:56:39 -05:00
|
|
|
// `matches` also includes the face itself
|
2024-02-17 04:32:11 +01:00
|
|
|
if (machineLearning.facialRecognition.minFaces > 1 && matches.length <= 1) {
|
|
|
|
|
this.logger.debug(`Face ${id} only matched the face itself, skipping`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2024-02-07 10:56:39 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.logger.debug(`Face ${id} has ${matches.length} matches`);
|
2024-01-18 00:08:48 -05:00
|
|
|
|
2024-04-17 23:47:24 -04:00
|
|
|
const isCore = matches.length >= machineLearning.facialRecognition.minFaces && !face.asset.isArchived;
|
2024-01-18 00:08:48 -05:00
|
|
|
if (!isCore && !deferred) {
|
|
|
|
|
this.logger.debug(`Deferring non-core face ${id} for later processing`);
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } });
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2025-01-09 11:15:41 -05:00
|
|
|
let personId = matches.find((match) => match.personId)?.personId;
|
2024-01-18 00:08:48 -05:00
|
|
|
if (!personId) {
|
2024-10-02 10:54:35 -04:00
|
|
|
const matchWithPerson = await this.searchRepository.searchFaces({
|
2024-01-18 00:08:48 -05:00
|
|
|
userIds: [face.asset.ownerId],
|
2024-06-16 15:25:27 -04:00
|
|
|
embedding: face.faceSearch.embedding,
|
2024-01-18 00:08:48 -05:00
|
|
|
maxDistance: machineLearning.facialRecognition.maxDistance,
|
|
|
|
|
numResults: 1,
|
|
|
|
|
hasPerson: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (matchWithPerson.length > 0) {
|
2025-01-09 11:15:41 -05:00
|
|
|
personId = matchWithPerson[0].personId;
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isCore && !personId) {
|
|
|
|
|
this.logger.log(`Creating new person for face ${id}`);
|
2024-10-02 10:54:35 -04:00
|
|
|
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
|
2024-01-18 00:08:48 -05:00
|
|
|
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
|
|
|
|
|
personId = newPerson.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (personId) {
|
|
|
|
|
this.logger.debug(`Assigning face ${id} to person ${personId}`);
|
2024-10-02 10:54:35 -04:00
|
|
|
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId });
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.MIGRATE_PERSON, queue: QueueName.MIGRATION })
|
|
|
|
|
async handlePersonMigration({ id }: JobOf<JobName.MIGRATE_PERSON>): Promise<JobStatus> {
|
2024-10-02 10:54:35 -04:00
|
|
|
const person = await this.personRepository.getById(id);
|
2023-09-27 22:46:46 +02:00
|
|
|
if (!person) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2023-10-11 04:14:44 +02:00
|
|
|
await this.storageCore.movePersonFile(person, PersonPathType.FACE);
|
2023-09-27 22:46:46 +02:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION })
|
|
|
|
|
async handleGeneratePersonThumbnail(data: JobOf<JobName.GENERATE_PERSON_THUMBNAIL>): Promise<JobStatus> {
|
2024-09-30 17:31:21 -04:00
|
|
|
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
|
2024-09-05 00:23:58 +02:00
|
|
|
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-02 10:54:35 -04:00
|
|
|
const person = await this.personRepository.getById(data.id);
|
2023-09-27 22:46:46 +02:00
|
|
|
if (!person?.faceAssetId) {
|
2024-05-08 09:09:34 -04:00
|
|
|
this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-02 10:54:35 -04:00
|
|
|
const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId);
|
2025-01-22 15:17:42 -05:00
|
|
|
if (!face) {
|
2024-05-08 09:09:34 -04:00
|
|
|
this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
assetId,
|
|
|
|
|
boundingBoxX1: x1,
|
|
|
|
|
boundingBoxX2: x2,
|
|
|
|
|
boundingBoxY1: y1,
|
|
|
|
|
boundingBoxY2: y2,
|
2024-06-12 22:16:26 -04:00
|
|
|
imageWidth: oldWidth,
|
|
|
|
|
imageHeight: oldHeight,
|
2023-09-27 22:46:46 +02:00
|
|
|
} = face;
|
|
|
|
|
|
2024-08-19 20:03:33 -04:00
|
|
|
const asset = await this.assetRepository.getById(assetId, {
|
|
|
|
|
exifInfo: true,
|
|
|
|
|
files: true,
|
|
|
|
|
});
|
2024-06-12 22:16:26 -04:00
|
|
|
if (!asset) {
|
|
|
|
|
this.logger.error(`Could not generate person thumbnail: asset ${assetId} does not exist`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
2024-05-08 09:09:34 -04:00
|
|
|
|
2024-06-16 11:45:58 -04:00
|
|
|
const { width, height, inputPath } = await this.getInputDimensions(asset, { width: oldWidth, height: oldHeight });
|
2024-06-12 22:16:26 -04:00
|
|
|
|
2023-10-23 17:52:21 +02:00
|
|
|
const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
|
2023-10-11 04:14:44 +02:00
|
|
|
this.storageCore.ensureFolders(thumbnailPath);
|
2023-09-27 22:46:46 +02:00
|
|
|
|
|
|
|
|
const thumbnailOptions = {
|
2024-09-28 13:47:24 -04:00
|
|
|
colorspace: image.colorspace,
|
2024-04-02 00:56:56 -04:00
|
|
|
format: ImageFormat.JPEG,
|
2023-09-27 22:46:46 +02:00
|
|
|
size: FACE_THUMBNAIL_SIZE,
|
2024-09-28 02:01:04 -04:00
|
|
|
quality: image.thumbnail.quality,
|
2024-06-12 22:16:26 -04:00
|
|
|
crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }),
|
2024-07-18 18:07:22 +02:00
|
|
|
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
2024-09-28 13:47:24 -04:00
|
|
|
};
|
2023-09-27 22:46:46 +02:00
|
|
|
|
2024-09-28 13:47:24 -04:00
|
|
|
await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath);
|
2024-10-02 10:54:35 -04:00
|
|
|
await this.personRepository.update({ id: person.id, thumbnailPath });
|
2023-09-27 22:46:46 +02:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-27 22:46:46 +02:00
|
|
|
}
|
|
|
|
|
|
2023-12-09 23:34:12 -05:00
|
|
|
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
|
2023-07-11 16:52:41 -05:00
|
|
|
const mergeIds = dto.ids;
|
2024-07-02 15:56:05 -04:00
|
|
|
if (mergeIds.includes(id)) {
|
|
|
|
|
throw new BadRequestException('Cannot merge a person into themselves');
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-10 11:53:53 -04:00
|
|
|
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
|
2024-01-19 18:52:26 +01:00
|
|
|
let primaryPerson = await this.findOrFail(id);
|
2023-07-11 16:52:41 -05:00
|
|
|
const primaryName = primaryPerson.name || primaryPerson.id;
|
|
|
|
|
|
|
|
|
|
const results: BulkIdResponseDto[] = [];
|
|
|
|
|
|
2024-10-10 11:53:53 -04:00
|
|
|
const allowedIds = await this.checkAccess({
|
2024-08-20 07:49:56 -04:00
|
|
|
auth,
|
|
|
|
|
permission: Permission.PERSON_MERGE,
|
|
|
|
|
ids: mergeIds,
|
|
|
|
|
});
|
2023-09-18 23:22:44 +02:00
|
|
|
|
2023-11-22 23:04:52 -05:00
|
|
|
for (const mergeId of mergeIds) {
|
|
|
|
|
const hasAccess = allowedIds.has(mergeId);
|
|
|
|
|
if (!hasAccess) {
|
2023-09-18 23:22:44 +02:00
|
|
|
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-11 16:52:41 -05:00
|
|
|
try {
|
2024-10-02 10:54:35 -04:00
|
|
|
const mergePerson = await this.personRepository.getById(mergeId);
|
2023-07-11 16:52:41 -05:00
|
|
|
if (!mergePerson) {
|
|
|
|
|
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-19 18:52:26 +01:00
|
|
|
const update: Partial<PersonEntity> = {};
|
|
|
|
|
if (!primaryPerson.name && mergePerson.name) {
|
|
|
|
|
update.name = mergePerson.name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!primaryPerson.birthDate && mergePerson.birthDate) {
|
|
|
|
|
update.birthDate = mergePerson.birthDate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Object.keys(update).length > 0) {
|
2024-10-02 10:54:35 -04:00
|
|
|
primaryPerson = await this.personRepository.update({ id: primaryPerson.id, ...update });
|
2024-01-19 18:52:26 +01:00
|
|
|
}
|
|
|
|
|
|
2023-07-11 16:52:41 -05:00
|
|
|
const mergeName = mergePerson.name || mergePerson.id;
|
|
|
|
|
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
|
|
|
|
|
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
|
|
|
|
|
|
2024-10-02 10:54:35 -04:00
|
|
|
await this.personRepository.reassignFaces(mergeData);
|
2024-01-18 00:08:48 -05:00
|
|
|
await this.delete([mergePerson]);
|
2023-07-11 16:52:41 -05:00
|
|
|
|
|
|
|
|
this.logger.log(`Merged ${mergeName} into ${primaryName}`);
|
|
|
|
|
results.push({ id: mergeId, success: true });
|
|
|
|
|
} catch (error: Error | any) {
|
|
|
|
|
this.logger.error(`Unable to merge ${mergeId} into ${id}: ${error}`, error?.stack);
|
|
|
|
|
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-18 23:22:44 +02:00
|
|
|
private async findOrFail(id: string) {
|
2024-10-02 10:54:35 -04:00
|
|
|
const person = await this.personRepository.getById(id);
|
2023-07-11 16:52:41 -05:00
|
|
|
if (!person) {
|
|
|
|
|
throw new BadRequestException('Person not found');
|
|
|
|
|
}
|
|
|
|
|
return person;
|
|
|
|
|
}
|
2024-05-08 09:09:34 -04:00
|
|
|
|
2024-06-16 11:45:58 -04:00
|
|
|
private async getInputDimensions(asset: AssetEntity, oldDims: ImageDimensions): Promise<InputDimensions> {
|
2024-06-12 22:16:26 -04:00
|
|
|
if (!asset.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) {
|
|
|
|
|
throw new Error(`Asset ${asset.id} dimensions are unknown`);
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-19 20:03:33 -04:00
|
|
|
const { previewFile } = getAssetFiles(asset.files);
|
|
|
|
|
if (!previewFile) {
|
2024-06-12 22:16:26 -04:00
|
|
|
throw new Error(`Asset ${asset.id} has no preview path`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (asset.type === AssetType.IMAGE) {
|
2024-06-16 11:45:58 -04:00
|
|
|
let { exifImageWidth: width, exifImageHeight: height } = asset.exifInfo;
|
|
|
|
|
if (oldDims.height > oldDims.width !== height > width) {
|
|
|
|
|
[width, height] = [height, width];
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-12 22:16:26 -04:00
|
|
|
return { width, height, inputPath: asset.originalPath };
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-19 20:03:33 -04:00
|
|
|
const { width, height } = await this.mediaRepository.getImageDimensions(previewFile.path);
|
|
|
|
|
return { width, height, inputPath: previewFile.path };
|
2024-06-12 22:16:26 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions {
|
|
|
|
|
const widthScale = dims.new.width / dims.old.width;
|
|
|
|
|
const heightScale = dims.new.height / dims.old.height;
|
|
|
|
|
|
|
|
|
|
const halfWidth = (widthScale * (x2 - x1)) / 2;
|
|
|
|
|
const halfHeight = (heightScale * (y2 - y1)) / 2;
|
|
|
|
|
|
|
|
|
|
const middleX = Math.round(widthScale * x1 + halfWidth);
|
|
|
|
|
const middleY = Math.round(heightScale * y1 + halfHeight);
|
|
|
|
|
|
|
|
|
|
// zoom out 10%
|
|
|
|
|
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
|
|
|
|
|
|
|
|
|
|
// get the longest distance from the center of the image without overflowing
|
|
|
|
|
const newHalfSize = Math.min(
|
|
|
|
|
middleX - Math.max(0, middleX - targetHalfSize),
|
|
|
|
|
middleY - Math.max(0, middleY - targetHalfSize),
|
|
|
|
|
Math.min(dims.new.width - 1, middleX + targetHalfSize) - middleX,
|
|
|
|
|
Math.min(dims.new.height - 1, middleY + targetHalfSize) - middleY,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
left: middleX - newHalfSize,
|
|
|
|
|
top: middleY - newHalfSize,
|
|
|
|
|
width: newHalfSize * 2,
|
|
|
|
|
height: newHalfSize * 2,
|
|
|
|
|
};
|
|
|
|
|
}
|
2023-05-17 13:07:17 -04:00
|
|
|
}
|