77e6a6d78b
* feat: faces-from-metadata - Import face regions from metadata Implements immich-app#1692. - OpenAPI spec changes to accomodate metadata face import configs. New settings to enable the feature. - Updates admin UI compoments - ML faces detection/recognition & Exif/Metadata faces compatibility Signed-off-by: BugFest <bugfest.dev@pm.me> * chore(web): remove unused file confirm-enable-import-faces * chore(web): format metadata-settings * fix(server): faces-from-metadata tests and format * fix(server): code refinements, nullable face asset sourceType * fix(server): Add RegionInfo to ImmichTags interface * fix(server): deleteAllFaces sourceType param can be undefined * fix(server): exiftool-vendored 27.0.0 moves readArgs into ExifToolOptions * fix(server): rename isImportFacesFromMetadataEnabled to isFaceImportEnabled * fix(server): simplify sourceType conditional * fix(server): small fixes * fix(server): handling sourceType * fix(server): sourceType enum * fix(server): refactor metadata applyTaggedFaces * fix(server): create/update signature changes * fix(server): reduce computational cost of Person.getManyByName * fix(server): use faceList instead of faceSet * fix(server): Skip regions without Name defined * fix(mobile): Update open-api (face assets feature changes) * fix(server): Face-Person reconciliation with map/index * fix(server): tags.RegionInfo.AppliedToDimensions must be defined to process face-region * fix(server): fix shared-link.service.ts format * fix(mobile): Update open-api after branch update * simplify * fix(server): minor fixes * fix(server): person create/update methods type enforcement * fix(server): style fixes * fix(server): remove unused metadata code * fix(server): metadata faces unit tests * fix(server): top level config metadata category * fix(server): rename upsertFaces to replaceFaces * fix(server): remove sourceType when unnecessary * fix(server): sourceType as ENUM * fix(server): format fixes * fix(server): fix tests after sourceType ENUM change * fix(server): remove unnecessary JobItem cast * fix(server): fix asset enum imports * fix(open-api): add metadata config * fix(mobile): update open-api after metadata open-api spec changes * fix(web): update web/api metadata config * fix(server): remove duplicated sourceType def * fix(server): update generated sql queries * fix(e2e): tests for metadata face import feature * fix(web): Fix check:typescript * fix(e2e): update subproject ref * fix(server): revert format changes to pass format checks after ci * fix(mobile): update open-api * fix(server,movile,open-api,mobile): sourceType as DB data type * fix(e2e): upload face asset after enabling metadata face import * fix(web): simplify metadata admin settings and i18n keys * Update person.repository.ts Co-authored-by: Jason Rasmussen <jason@rasm.me> * fix(server): asset_faces.sourceType column not nullable * fix(server): simplified syntax * fix(e2e): use SDK for everything except the endpoint being tested * fix(e2e): fix test format * chore: clean up * chore: clean up * chore: update e2e/test-assets --------- Signed-off-by: BugFest <bugfest.dev@pm.me> Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Jason Rasmussen <jason@rasm.me>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
|
import _ from 'lodash';
|
|
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
|
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
|
import { AssetEntity } from 'src/entities/asset.entity';
|
|
import { PersonEntity } from 'src/entities/person.entity';
|
|
import {
|
|
AssetFaceId,
|
|
DeleteAllFacesOptions,
|
|
IPersonRepository,
|
|
PeopleStatistics,
|
|
PersonNameResponse,
|
|
PersonNameSearchOptions,
|
|
PersonSearchOptions,
|
|
PersonStatistics,
|
|
UpdateFacesData,
|
|
} from 'src/interfaces/person.interface';
|
|
import { Instrumentation } from 'src/utils/instrumentation';
|
|
import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
|
|
import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
|
|
|
|
@Instrumentation()
|
|
@Injectable()
|
|
export class PersonRepository implements IPersonRepository {
|
|
constructor(
|
|
@InjectDataSource() private dataSource: DataSource,
|
|
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
|
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
|
|
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
|
|
@InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository<AssetJobStatusEntity>,
|
|
) {}
|
|
|
|
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
|
|
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
|
|
const result = await this.assetFaceRepository
|
|
.createQueryBuilder()
|
|
.update()
|
|
.set({ personId: newPersonId })
|
|
.where(_.omitBy({ personId: oldPersonId ?? undefined, id: faceIds ? In(faceIds) : undefined }, _.isUndefined))
|
|
.execute();
|
|
|
|
return result.affected ?? 0;
|
|
}
|
|
|
|
async delete(entities: PersonEntity[]): Promise<void> {
|
|
await this.personRepository.remove(entities);
|
|
}
|
|
|
|
async deleteAll(): Promise<void> {
|
|
await this.personRepository.clear();
|
|
}
|
|
|
|
async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise<void> {
|
|
if (sourceType) {
|
|
await this.assetFaceRepository
|
|
.createQueryBuilder('asset_faces')
|
|
.delete()
|
|
.andWhere('sourceType = :sourceType', { sourceType })
|
|
.execute();
|
|
return;
|
|
}
|
|
|
|
await this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
|
|
}
|
|
|
|
getAllFaces(
|
|
pagination: PaginationOptions,
|
|
options: FindManyOptions<AssetFaceEntity> = {},
|
|
): Paginated<AssetFaceEntity> {
|
|
return paginate(this.assetFaceRepository, pagination, options);
|
|
}
|
|
|
|
getAll(pagination: PaginationOptions, options: FindManyOptions<PersonEntity> = {}): Paginated<PersonEntity> {
|
|
return paginate(this.personRepository, pagination, options);
|
|
}
|
|
|
|
@GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] })
|
|
getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions): Paginated<PersonEntity> {
|
|
const queryBuilder = this.personRepository
|
|
.createQueryBuilder('person')
|
|
.leftJoin('person.faces', 'face')
|
|
.where('person.ownerId = :userId', { userId })
|
|
.innerJoin('face.asset', 'asset')
|
|
.andWhere('asset.isArchived = false')
|
|
.orderBy('person.isHidden', 'ASC')
|
|
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
|
|
.addOrderBy('COUNT(face.assetId)', 'DESC')
|
|
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
|
|
.addOrderBy('person.createdAt')
|
|
.andWhere("person.thumbnailPath != ''")
|
|
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
|
|
.groupBy('person.id');
|
|
if (!options?.withHidden) {
|
|
queryBuilder.andWhere('person.isHidden = false');
|
|
}
|
|
|
|
return paginatedBuilder(queryBuilder, {
|
|
mode: PaginationMode.LIMIT_OFFSET,
|
|
...pagination,
|
|
});
|
|
}
|
|
|
|
@GenerateSql()
|
|
getAllWithoutFaces(): Promise<PersonEntity[]> {
|
|
return this.personRepository
|
|
.createQueryBuilder('person')
|
|
.leftJoin('person.faces', 'face')
|
|
.having('COUNT(face.assetId) = 0')
|
|
.groupBy('person.id')
|
|
.withDeleted()
|
|
.getMany();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getFaces(assetId: string): Promise<AssetFaceEntity[]> {
|
|
return this.assetFaceRepository.find({
|
|
where: { assetId },
|
|
relations: {
|
|
person: true,
|
|
},
|
|
order: {
|
|
boundingBoxX1: 'ASC',
|
|
},
|
|
});
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getFaceById(id: string): Promise<AssetFaceEntity> {
|
|
// TODO return null instead of find or fail
|
|
return this.assetFaceRepository.findOneOrFail({
|
|
where: { id },
|
|
relations: {
|
|
person: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getFaceByIdWithAssets(
|
|
id: string,
|
|
relations: FindOptionsRelations<AssetFaceEntity>,
|
|
select: FindOptionsSelect<AssetFaceEntity>,
|
|
): Promise<AssetFaceEntity | null> {
|
|
return this.assetFaceRepository.findOne(
|
|
_.omitBy(
|
|
{
|
|
where: { id },
|
|
relations: {
|
|
...relations,
|
|
person: true,
|
|
asset: true,
|
|
},
|
|
select,
|
|
},
|
|
_.isUndefined,
|
|
),
|
|
);
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
|
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
|
|
const result = await this.assetFaceRepository
|
|
.createQueryBuilder()
|
|
.update()
|
|
.set({ personId: newPersonId })
|
|
.where({ id: assetFaceId })
|
|
.execute();
|
|
|
|
return result.affected ?? 0;
|
|
}
|
|
|
|
getById(personId: string): Promise<PersonEntity | null> {
|
|
return this.personRepository.findOne({ where: { id: personId } });
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
|
|
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
|
|
const queryBuilder = this.personRepository
|
|
.createQueryBuilder('person')
|
|
.leftJoin('person.faces', 'face')
|
|
.where(
|
|
'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)',
|
|
{ userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` },
|
|
)
|
|
.groupBy('person.id')
|
|
.orderBy('COUNT(face.assetId)', 'DESC')
|
|
.limit(20);
|
|
|
|
if (!withHidden) {
|
|
queryBuilder.andWhere('person.isHidden = false');
|
|
}
|
|
return queryBuilder.getMany();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
|
|
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
|
|
const queryBuilder = this.personRepository
|
|
.createQueryBuilder('person')
|
|
.select(['person.id', 'person.name'])
|
|
.distinctOn(['lower(person.name)'])
|
|
.where(`person.ownerId = :userId AND person.name != ''`, { userId });
|
|
|
|
if (!withHidden) {
|
|
queryBuilder.andWhere('person.isHidden = false');
|
|
}
|
|
|
|
return queryBuilder.getMany();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
async getStatistics(personId: string): Promise<PersonStatistics> {
|
|
const items = await this.assetFaceRepository
|
|
.createQueryBuilder('face')
|
|
.leftJoin('face.asset', 'asset')
|
|
.where('face.personId = :personId', { personId })
|
|
.andWhere('asset.isArchived = false')
|
|
.andWhere('asset.deletedAt IS NULL')
|
|
.andWhere('asset.livePhotoVideoId IS NULL')
|
|
.select('COUNT(DISTINCT(asset.id))', 'count')
|
|
.getRawOne();
|
|
return {
|
|
assets: items.count ?? 0,
|
|
};
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getAssets(personId: string): Promise<AssetEntity[]> {
|
|
return this.assetRepository.find({
|
|
where: {
|
|
faces: {
|
|
personId,
|
|
},
|
|
isVisible: true,
|
|
isArchived: false,
|
|
},
|
|
relations: {
|
|
faces: {
|
|
person: true,
|
|
},
|
|
exifInfo: true,
|
|
},
|
|
order: {
|
|
fileCreatedAt: 'desc',
|
|
},
|
|
// TODO: remove after either (1) pagination or (2) time bucket is implemented for this query
|
|
take: 1000,
|
|
});
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
async getNumberOfPeople(userId: string): Promise<PeopleStatistics> {
|
|
const items = await this.personRepository
|
|
.createQueryBuilder('person')
|
|
.leftJoin('person.faces', 'face')
|
|
.where('person.ownerId = :userId', { userId })
|
|
.innerJoin('face.asset', 'asset')
|
|
.andWhere('asset.isArchived = false')
|
|
.andWhere("person.thumbnailPath != ''")
|
|
.select('COUNT(DISTINCT(person.id))', 'total')
|
|
.addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden')
|
|
.having('COUNT(face.assetId) != 0')
|
|
.getRawOne();
|
|
|
|
if (items == undefined) {
|
|
return { total: 0, hidden: 0 };
|
|
}
|
|
|
|
const result: PeopleStatistics = {
|
|
total: items.total ?? 0,
|
|
hidden: items.hidden ?? 0,
|
|
};
|
|
|
|
return result;
|
|
}
|
|
|
|
create(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]> {
|
|
return this.personRepository.save(entities);
|
|
}
|
|
|
|
async createFaces(entities: AssetFaceEntity[]): Promise<string[]> {
|
|
const res = await this.assetFaceRepository.save(entities);
|
|
return res.map((row) => row.id);
|
|
}
|
|
|
|
async replaceFaces(assetId: string, entities: AssetFaceEntity[], sourceType: string): Promise<string[]> {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
await manager.delete(AssetFaceEntity, { assetId, sourceType });
|
|
const assetFaces = await manager.save(AssetFaceEntity, entities);
|
|
return assetFaces.map(({ id }) => id);
|
|
});
|
|
}
|
|
|
|
async update(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]> {
|
|
return await this.personRepository.save(entities);
|
|
}
|
|
|
|
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
|
@ChunkedArray()
|
|
async getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
|
|
return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true });
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
async getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
|
|
return this.assetFaceRepository.findOneBy({ personId });
|
|
}
|
|
|
|
@GenerateSql()
|
|
async getLatestFaceDate(): Promise<string | undefined> {
|
|
const result: { latestDate?: string } | undefined = await this.jobStatusRepository
|
|
.createQueryBuilder('jobStatus')
|
|
.select('MAX(jobStatus.facesRecognizedAt)::text', 'latestDate')
|
|
.getRawOne();
|
|
return result?.latestDate;
|
|
}
|
|
}
|