feat(server): don't re-run face recognition on assets without any faces (#4854)

* Add AssetJobStatus

* fentity

* Add jobStatus field to AssetEntity

* Fix the migration doc paths

* Filter on facesRecognizedAt

* Set facesRecognizedAt field

* Test for facesRecognizedAt

* Done testing

* Adjust FK properties

* Add tests for WithoutProperty.FACES

* chore: non-nullable

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Sushain Cherivirala
2023-11-09 17:55:00 -08:00
committed by GitHub
parent 75c065c83a
commit 986bbfa831
11 changed files with 119 additions and 3 deletions

View File

@@ -467,6 +467,8 @@ describe(PersonService.name, () => {
});
it('should handle no results', async () => {
const start = Date.now();
machineLearningMock.detectFaces.mockResolvedValue([]);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleRecognizeFaces({ id: assetStub.image.id });
@@ -485,6 +487,12 @@ describe(PersonService.name, () => {
);
expect(personMock.createFace).not.toHaveBeenCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({
assetId: assetStub.image.id,
facesRecognizedAt: expect.any(Date),
});
expect(assetMock.upsertJobStatus.mock.calls[0][0].facesRecognizedAt?.getTime()).toBeGreaterThan(start);
});
it('should match existing people', async () => {

View File

@@ -274,6 +274,11 @@ export class PersonService {
}
}
await this.assetRepository.upsertJobStatus({
assetId: asset.id,
facesRecognizedAt: new Date(),
});
return true;
}

View File

@@ -1,4 +1,4 @@
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { FindOptionsRelations } from 'typeorm';
import { Paginated, PaginationOptions } from '../domain.util';
@@ -126,4 +126,5 @@ export interface IAssetRepository {
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity>): Promise<void>;
}

View File

@@ -0,0 +1,15 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
import { AssetEntity } from './asset.entity';
@Entity('asset_job_status')
export class AssetJobStatusEntity {
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@JoinColumn()
asset!: AssetEntity;
@PrimaryColumn()
assetId!: string;
@Column({ type: 'timestamptz', nullable: true })
facesRecognizedAt!: Date | null;
}

View File

@@ -15,6 +15,7 @@ import {
} from 'typeorm';
import { AlbumEntity } from './album.entity';
import { AssetFaceEntity } from './asset-face.entity';
import { AssetJobStatusEntity } from './asset-job-status.entity';
import { ExifEntity } from './exif.entity';
import { LibraryEntity } from './library.entity';
import { SharedLinkEntity } from './shared-link.entity';
@@ -158,6 +159,9 @@ export class AssetEntity {
@OneToMany(() => AssetEntity, (asset) => asset.stackParent)
stack?: AssetEntity[];
@OneToOne(() => AssetJobStatusEntity, (jobStatus) => jobStatus.asset, { nullable: true })
jobStatus?: AssetJobStatusEntity;
}
export enum AssetType {

View File

@@ -2,6 +2,7 @@ import { ActivityEntity } from './activity.entity';
import { AlbumEntity } from './album.entity';
import { APIKeyEntity } from './api-key.entity';
import { AssetFaceEntity } from './asset-face.entity';
import { AssetJobStatusEntity } from './asset-job-status.entity';
import { AssetEntity } from './asset.entity';
import { AuditEntity } from './audit.entity';
import { ExifEntity } from './exif.entity';
@@ -20,6 +21,7 @@ export * from './activity.entity';
export * from './album.entity';
export * from './api-key.entity';
export * from './asset-face.entity';
export * from './asset-job-status.entity';
export * from './asset.entity';
export * from './audit.entity';
export * from './exif.entity';
@@ -40,6 +42,7 @@ export const databaseEntities = [
APIKeyEntity,
AssetEntity,
AssetFaceEntity,
AssetJobStatusEntity,
AuditEntity,
ExifEntity,
MoveEntity,

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddJobStatus1699345863886 implements MigrationInterface {
name = 'AddJobStatus1699345863886'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "asset_job_status" ("assetId" uuid NOT NULL, "facesRecognizedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_420bec36fc02813bddf5c8b73d4" PRIMARY KEY ("assetId"))`);
await queryRunner.query(`ALTER TABLE "asset_job_status" ADD CONSTRAINT "FK_420bec36fc02813bddf5c8b73d4" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_job_status" DROP CONSTRAINT "FK_420bec36fc02813bddf5c8b73d4"`);
await queryRunner.query(`DROP TABLE "asset_job_status"`);
}
}

View File

@@ -20,7 +20,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DateTime } from 'luxon';
import { And, FindOptionsRelations, FindOptionsWhere, In, IsNull, LessThan, Not, Repository } from 'typeorm';
import { AssetEntity, AssetType, ExifEntity } from '../entities';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '../entities';
import OptionalBetween from '../utils/optional-between.util';
import { paginate } from '../utils/pagination.util';
@@ -39,12 +39,17 @@ export class AssetRepository implements IAssetRepository {
constructor(
@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository<AssetJobStatusEntity>,
) {}
async upsertExif(exif: Partial<ExifEntity>): Promise<void> {
await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] });
}
async upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity>): Promise<void> {
await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] });
}
create(asset: AssetCreate): Promise<AssetEntity> {
return this.repository.save(asset);
}
@@ -323,6 +328,7 @@ export class AssetRepository implements IAssetRepository {
case WithoutProperty.FACES:
relations = {
faces: true,
jobStatus: true,
};
where = {
resizePath: Not(IsNull()),
@@ -331,6 +337,9 @@ export class AssetRepository implements IAssetRepository {
assetId: IsNull(),
personId: IsNull(),
},
jobStatus: {
facesRecognizedAt: IsNull(),
},
};
break;