Skip to content

Commit

Permalink
refresh faces
Browse files Browse the repository at this point in the history
  • Loading branch information
mertalev committed Sep 5, 2024
1 parent 0d6bef2 commit d13f1e8
Show file tree
Hide file tree
Showing 20 changed files with 222 additions and 91 deletions.
9 changes: 6 additions & 3 deletions mobile/openapi/lib/model/asset_job_name.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 14 additions & 5 deletions mobile/openapi/lib/model/job_command_dto.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -8170,8 +8170,9 @@
},
"AssetJobName": {
"enum": [
"regenerate-thumbnail",
"refresh-faces",
"refresh-metadata",
"regenerate-thumbnail",
"transcode-video"
],
"type": "string"
Expand Down Expand Up @@ -9230,8 +9231,7 @@
}
},
"required": [
"command",
"force"
"command"
],
"type": "object"
},
Expand Down
5 changes: 3 additions & 2 deletions open-api/typescript-sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ export type AllJobStatusResponseDto = {
};
export type JobCommandDto = {
command: JobCommand;
force: boolean;
force?: boolean;
};
export type LibraryResponseDto = {
assetCount: number;
Expand Down Expand Up @@ -3350,8 +3350,9 @@ export enum Reason {
UnsupportedFormat = "unsupported-format"
}
export enum AssetJobName {
RegenerateThumbnail = "regenerate-thumbnail",
RefreshFaces = "refresh-faces",
RefreshMetadata = "refresh-metadata",
RegenerateThumbnail = "regenerate-thumbnail",
TranscodeVideo = "transcode-video"
}
export enum AssetMediaSize {
Expand Down
3 changes: 2 additions & 1 deletion server/src/dtos/asset.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ export class AssetIdsDto {
}

export enum AssetJobName {
REGENERATE_THUMBNAIL = 'regenerate-thumbnail',
REFRESH_FACES = 'refresh-faces',
REFRESH_METADATA = 'refresh-metadata',
REGENERATE_THUMBNAIL = 'regenerate-thumbnail',
TRANSCODE_VIDEO = 'transcode-video',
}

Expand Down
2 changes: 1 addition & 1 deletion server/src/dtos/job.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class JobCommandDto {
command!: JobCommand;

@ValidateBoolean({ optional: true })
force!: boolean;
force?: boolean;
}

export class JobCountsDto {
Expand Down
2 changes: 1 addition & 1 deletion server/src/interfaces/person.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export interface IPersonRepository {
delete(entities: PersonEntity[]): Promise<void>;
deleteAll(): Promise<void>;
deleteAllFaces(options: DeleteAllFacesOptions): Promise<void>;
replaceFaces(assetId: string, entities: Partial<AssetFaceEntity>[], sourceType?: string): Promise<string[]>;
refreshFaces(facesToAdd: Partial<AssetFaceEntity>[], faceIdsToRemove: string[]): Promise<void>;
getAllFaces(pagination: PaginationOptions, options?: FindManyOptions<AssetFaceEntity>): Paginated<AssetFaceEntity>;
getFaceById(id: string): Promise<AssetFaceEntity>;
getFaceByIdWithAssets(
Expand Down
1 change: 0 additions & 1 deletion server/src/repositories/asset.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@ export class AssetRepository implements IAssetRepository {
relations: {
exifInfo: true,
smartInfo: true,
tags: true,
faces: {
person: true,
},
Expand Down
28 changes: 22 additions & 6 deletions server/src/repositories/person.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 { FaceSearchEntity } from 'src/entities/face-search.entity';
import { PersonEntity } from 'src/entities/person.entity';
import {
AssetFaceId,
Expand All @@ -29,6 +30,7 @@ export class PersonRepository implements IPersonRepository {
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(FaceSearchEntity) private faceSearchRepository: Repository<FaceSearchEntity>,
@InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository<AssetJobStatusEntity>,
) {}

Expand Down Expand Up @@ -284,12 +286,26 @@ export class PersonRepository implements IPersonRepository {
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 refreshFaces(facesToAdd: Partial<AssetFaceEntity>[], faceIdsToRemove: string[]): Promise<void> {
const deletedCte = this.assetFaceRepository
.createQueryBuilder()
.delete()
.where('id = ANY(:faceIdsToRemove)', { faceIdsToRemove });

if (facesToAdd.length === 0) {
await deletedCte.execute();
return;
}

const addedCte = this.assetFaceRepository.createQueryBuilder().insert().values(facesToAdd);

await this.faceSearchRepository
.createQueryBuilder()
.addCommonTableExpression(deletedCte, 'deleted')
.addCommonTableExpression(addedCte, 'added')
.insert()
.values(facesToAdd.map((face) => face.faceSearch!))
.execute();
}

async update(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]> {
Expand Down
7 changes: 5 additions & 2 deletions server/src/services/asset.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ export class AssetService {
id,
{
exifInfo: true,
tags: true,
sharedLinks: true,
smartInfo: true,
owner: true,
Expand Down Expand Up @@ -166,7 +165,6 @@ export class AssetService {
exifInfo: true,
owner: true,
smartInfo: true,
tags: true,
faces: {
person: true,
},
Expand Down Expand Up @@ -298,6 +296,11 @@ export class AssetService {

for (const id of dto.assetIds) {
switch (dto.name) {
case AssetJobName.REFRESH_FACES: {
jobs.push({ name: JobName.FACE_DETECTION, data: { id } });
break;
}

case AssetJobName.REFRESH_METADATA: {
jobs.push({ name: JobName.METADATA_EXTRACTION, data: { id } });
break;
Expand Down
16 changes: 8 additions & 8 deletions server/src/services/metadata.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,11 +981,11 @@ describe(MetadataService.name, () => {
metadataMock.readTags.mockResolvedValue(metadataStub.withFaceNoName);
personMock.getDistinctNames.mockResolvedValue([]);
personMock.create.mockResolvedValue([]);
personMock.replaceFaces.mockResolvedValue([]);
personMock.refreshFaces.mockResolvedValue([]);
personMock.update.mockResolvedValue([]);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(personMock.create).toHaveBeenCalledWith([]);
expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF);
expect(personMock.refreshFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF);
expect(personMock.update).toHaveBeenCalledWith([]);
});

Expand All @@ -995,11 +995,11 @@ describe(MetadataService.name, () => {
metadataMock.readTags.mockResolvedValue(metadataStub.withFaceEmptyName);
personMock.getDistinctNames.mockResolvedValue([]);
personMock.create.mockResolvedValue([]);
personMock.replaceFaces.mockResolvedValue([]);
personMock.refreshFaces.mockResolvedValue([]);
personMock.update.mockResolvedValue([]);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(personMock.create).toHaveBeenCalledWith([]);
expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF);
expect(personMock.refreshFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF);
expect(personMock.update).toHaveBeenCalledWith([]);
});

Expand All @@ -1009,13 +1009,13 @@ describe(MetadataService.name, () => {
metadataMock.readTags.mockResolvedValue(metadataStub.withFace);
personMock.getDistinctNames.mockResolvedValue([]);
personMock.create.mockResolvedValue([personStub.withName]);
personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']);
personMock.refreshFaces.mockResolvedValue(['face-asset-uuid']);
personMock.update.mockResolvedValue([personStub.withName]);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]);
expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
expect(personMock.create).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]);
expect(personMock.replaceFaces).toHaveBeenCalledWith(
expect(personMock.refreshFaces).toHaveBeenCalledWith(
assetStub.primaryImage.id,
[
{
Expand Down Expand Up @@ -1048,13 +1048,13 @@ describe(MetadataService.name, () => {
metadataMock.readTags.mockResolvedValue(metadataStub.withFace);
personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
personMock.create.mockResolvedValue([]);
personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']);
personMock.refreshFaces.mockResolvedValue(['face-asset-uuid']);
personMock.update.mockResolvedValue([personStub.withName]);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]);
expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
expect(personMock.create).toHaveBeenCalledWith([]);
expect(personMock.replaceFaces).toHaveBeenCalledWith(
expect(personMock.refreshFaces).toHaveBeenCalledWith(
assetStub.primaryImage.id,
[
{
Expand Down
34 changes: 21 additions & 13 deletions server/src/services/metadata.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export class MetadataService {

async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> {
const { metadata } = await this.configCore.getConfig({ withCache: true });
const [asset] = await this.assetRepository.getByIds([id]);
const [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } });
if (!asset) {
return JobStatus.FAILED;
}
Expand Down Expand Up @@ -525,7 +525,7 @@ export class MetadataService {
return;
}

const discoveredFaces: Partial<AssetFaceEntity>[] = [];
const facesToAdd: Partial<AssetFaceEntity>[] = [];
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
const missing: Partial<PersonEntity>[] = [];
Expand Down Expand Up @@ -553,7 +553,7 @@ export class MetadataService {
sourceType: SourceType.EXIF,
};

discoveredFaces.push(face);
facesToAdd.push(face);
if (!existingNameMap.has(loweredName)) {
missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name });
missingWithFaceAsset.push({ id: personId, faceAssetId: face.id });
Expand All @@ -562,21 +562,29 @@ export class MetadataService {

if (missing.length > 0) {
this.logger.debug(`Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`);
const newPersons = await this.personRepository.create(missing);
const jobs = newPersons.map(
(person) => ({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }) as const,
);
await this.jobRepository.queueAll(jobs);
}

const newPersons = await this.personRepository.create(missing);
const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.EXIF).map((face) => face.id);
if (facesToRemove.length > 0) {
this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}`);
}

const faceIds = await this.personRepository.replaceFaces(asset.id, discoveredFaces, SourceType.EXIF);
this.logger.debug(`Created ${faceIds.length} faces for asset ${asset.id}`);
if (facesToAdd.length > 0) {
this.logger.debug(`Creating ${facesToAdd} faces from metadata for asset ${asset.id}`);
}

await this.personRepository.update(missingWithFaceAsset);
if (facesToRemove.length > 0 || facesToAdd.length > 0) {
await this.personRepository.refreshFaces(facesToAdd, facesToRemove);
}

await this.jobRepository.queueAll(
newPersons.map((person) => ({
name: JobName.GENERATE_PERSON_THUMBNAIL,
data: { id: person.id },
})),
);
if (missingWithFaceAsset.length > 0) {
await this.personRepository.update(missingWithFaceAsset);
}
}

private async exifData(
Expand Down
Loading

0 comments on commit d13f1e8

Please sign in to comment.