2023-05-17 19:07:17 +02:00
|
|
|
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
2023-07-11 23:52:41 +02:00
|
|
|
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
|
2023-05-17 19:07:17 +02:00
|
|
|
import { AuthUserDto } from '../auth';
|
2023-07-10 19:56:45 +02:00
|
|
|
import { mimeTypes } from '../domain.constant';
|
2023-05-17 19:07:17 +02:00
|
|
|
import { IJobRepository, JobName } from '../job';
|
|
|
|
import { ImmichReadStream, IStorageRepository } from '../storage';
|
2023-07-18 20:09:43 +02:00
|
|
|
import {
|
|
|
|
mapPerson,
|
|
|
|
MergePersonDto,
|
|
|
|
PeopleResponseDto,
|
|
|
|
PersonResponseDto,
|
|
|
|
PersonSearchDto,
|
|
|
|
PersonUpdateDto,
|
|
|
|
} from './person.dto';
|
2023-07-11 23:52:41 +02:00
|
|
|
import { IPersonRepository, UpdateFacesData } from './person.repository';
|
2023-05-17 19:07:17 +02:00
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
export class PersonService {
|
|
|
|
readonly logger = new Logger(PersonService.name);
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
@Inject(IPersonRepository) private repository: IPersonRepository,
|
|
|
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
|
|
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
|
|
|
) {}
|
|
|
|
|
2023-07-18 20:09:43 +02:00
|
|
|
async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
|
2023-05-17 19:07:17 +02:00
|
|
|
const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 });
|
|
|
|
const named = people.filter((person) => !!person.name);
|
|
|
|
const unnamed = people.filter((person) => !person.name);
|
2023-07-18 20:09:43 +02:00
|
|
|
|
|
|
|
const persons: PersonResponseDto[] = [...named, ...unnamed]
|
|
|
|
// with thumbnails
|
|
|
|
.filter((person) => !!person.thumbnailPath)
|
|
|
|
.map((person) => mapPerson(person));
|
|
|
|
|
|
|
|
return {
|
|
|
|
people: persons.filter((person) => dto.withHidden || !person.isHidden),
|
|
|
|
total: persons.length,
|
|
|
|
visible: persons.filter((person: PersonResponseDto) => !person.isHidden).length,
|
|
|
|
};
|
2023-05-17 19:07:17 +02:00
|
|
|
}
|
|
|
|
|
2023-07-11 23:52:41 +02:00
|
|
|
getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
|
|
|
|
return this.findOrFail(authUser, id).then(mapPerson);
|
2023-05-17 19:07:17 +02:00
|
|
|
}
|
|
|
|
|
2023-07-11 23:52:41 +02:00
|
|
|
async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
|
|
|
|
const person = await this.repository.getById(authUser.id, id);
|
2023-05-17 19:07:17 +02:00
|
|
|
if (!person || !person.thumbnailPath) {
|
|
|
|
throw new NotFoundException();
|
|
|
|
}
|
|
|
|
|
2023-07-10 19:56:45 +02:00
|
|
|
return this.storageRepository.createReadStream(person.thumbnailPath, mimeTypes.lookup(person.thumbnailPath));
|
2023-05-17 19:07:17 +02:00
|
|
|
}
|
|
|
|
|
2023-07-11 23:52:41 +02:00
|
|
|
async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
|
|
|
|
const assets = await this.repository.getAssets(authUser.id, id);
|
2023-05-17 19:07:17 +02:00
|
|
|
return assets.map(mapAsset);
|
|
|
|
}
|
|
|
|
|
2023-07-11 23:52:41 +02:00
|
|
|
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
|
|
|
let person = await this.findOrFail(authUser, id);
|
2023-05-17 19:07:17 +02:00
|
|
|
|
2023-07-18 20:09:43 +02:00
|
|
|
if (dto.name != undefined || dto.isHidden !== undefined) {
|
|
|
|
person = await this.repository.update({ id, name: dto.name, isHidden: dto.isHidden });
|
2023-07-11 23:52:41 +02:00
|
|
|
const assets = await this.repository.getAssets(authUser.id, id);
|
|
|
|
const ids = assets.map((asset) => asset.id);
|
|
|
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
|
2023-07-03 00:46:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if (dto.featureFaceAssetId) {
|
2023-07-11 23:52:41 +02:00
|
|
|
const assetId = dto.featureFaceAssetId;
|
|
|
|
const face = await this.repository.getFaceById({ personId: id, assetId });
|
|
|
|
if (!face) {
|
|
|
|
throw new BadRequestException('Invalid assetId for feature face');
|
|
|
|
}
|
2023-07-03 00:46:20 +02:00
|
|
|
|
2023-07-11 23:52:41 +02:00
|
|
|
await this.jobRepository.queue({
|
|
|
|
name: JobName.GENERATE_FACE_THUMBNAIL,
|
|
|
|
data: {
|
|
|
|
personId: id,
|
|
|
|
assetId,
|
|
|
|
boundingBox: {
|
|
|
|
x1: face.boundingBoxX1,
|
|
|
|
x2: face.boundingBoxX2,
|
|
|
|
y1: face.boundingBoxY1,
|
|
|
|
y2: face.boundingBoxY2,
|
|
|
|
},
|
|
|
|
imageHeight: face.imageHeight,
|
|
|
|
imageWidth: face.imageWidth,
|
|
|
|
},
|
|
|
|
});
|
2023-07-03 00:46:20 +02:00
|
|
|
}
|
|
|
|
|
2023-07-11 23:52:41 +02:00
|
|
|
return mapPerson(person);
|
2023-05-17 19:07:17 +02:00
|
|
|
}
|
|
|
|
|
2023-05-26 21:43:24 +02:00
|
|
|
async handlePersonCleanup() {
|
2023-05-17 19:07:17 +02:00
|
|
|
const people = await this.repository.getAllWithoutFaces();
|
|
|
|
for (const person of people) {
|
|
|
|
this.logger.debug(`Person ${person.name || person.id} no longer has any faces, deleting.`);
|
|
|
|
try {
|
|
|
|
await this.repository.delete(person);
|
|
|
|
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [person.thumbnailPath] } });
|
|
|
|
} catch (error: Error | any) {
|
|
|
|
this.logger.error(`Unable to delete person: ${error}`, error?.stack);
|
|
|
|
}
|
|
|
|
}
|
2023-05-26 21:43:24 +02:00
|
|
|
|
|
|
|
return true;
|
2023-05-17 19:07:17 +02:00
|
|
|
}
|
2023-07-11 23:52:41 +02:00
|
|
|
|
|
|
|
async mergePerson(authUser: AuthUserDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
|
|
|
|
const mergeIds = dto.ids;
|
|
|
|
const primaryPerson = await this.findOrFail(authUser, id);
|
|
|
|
const primaryName = primaryPerson.name || primaryPerson.id;
|
|
|
|
|
|
|
|
const results: BulkIdResponseDto[] = [];
|
|
|
|
|
|
|
|
for (const mergeId of mergeIds) {
|
|
|
|
try {
|
|
|
|
const mergePerson = await this.repository.getById(authUser.id, mergeId);
|
|
|
|
if (!mergePerson) {
|
|
|
|
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
const mergeName = mergePerson.name || mergePerson.id;
|
|
|
|
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
|
|
|
|
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
|
|
|
|
|
|
|
|
const assetIds = await this.repository.prepareReassignFaces(mergeData);
|
|
|
|
for (const assetId of assetIds) {
|
|
|
|
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId: mergeId } });
|
|
|
|
}
|
|
|
|
await this.repository.reassignFaces(mergeData);
|
|
|
|
await this.repository.delete(mergePerson);
|
|
|
|
|
|
|
|
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 });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-22 21:42:12 +02:00
|
|
|
// Re-index all faces in typesense for up-to-date search results
|
|
|
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACES });
|
|
|
|
|
2023-07-11 23:52:41 +02:00
|
|
|
return results;
|
|
|
|
}
|
|
|
|
|
|
|
|
private async findOrFail(authUser: AuthUserDto, id: string) {
|
|
|
|
const person = await this.repository.getById(authUser.id, id);
|
|
|
|
if (!person) {
|
|
|
|
throw new BadRequestException('Person not found');
|
|
|
|
}
|
|
|
|
return person;
|
|
|
|
}
|
2023-05-17 19:07:17 +02:00
|
|
|
}
|